@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.
- package/README.md +1 -1
- package/lib/browser/catalog.json +6 -0
- package/lib/browser/messaging/ws-connection-source.d.ts.map +1 -1
- package/lib/browser/messaging/ws-connection-source.js +1 -6
- package/lib/browser/messaging/ws-connection-source.js.map +1 -1
- package/lib/browser/tree/tree-view-welcome-widget.d.ts +1 -0
- package/lib/browser/tree/tree-view-welcome-widget.d.ts.map +1 -1
- package/lib/browser/tree/tree-view-welcome-widget.js +8 -6
- package/lib/browser/tree/tree-view-welcome-widget.js.map +1 -1
- package/lib/browser/tree/tree-view-welcome-widget.spec.d.ts +2 -0
- package/lib/browser/tree/tree-view-welcome-widget.spec.d.ts.map +1 -0
- package/lib/browser/tree/tree-view-welcome-widget.spec.js +68 -0
- package/lib/browser/tree/tree-view-welcome-widget.spec.js.map +1 -0
- package/lib/electron-node/hosting/electron-ws-origin-validator.d.ts.map +1 -1
- package/lib/electron-node/hosting/electron-ws-origin-validator.js +5 -2
- package/lib/electron-node/hosting/electron-ws-origin-validator.js.map +1 -1
- package/lib/electron-node/hosting/electron-ws-origin-validator.spec.d.ts +2 -0
- package/lib/electron-node/hosting/electron-ws-origin-validator.spec.d.ts.map +1 -0
- package/lib/electron-node/hosting/electron-ws-origin-validator.spec.js +59 -0
- package/lib/electron-node/hosting/electron-ws-origin-validator.spec.js.map +1 -0
- package/lib/node/backend-application-module.d.ts.map +1 -1
- package/lib/node/backend-application-module.js +2 -0
- package/lib/node/backend-application-module.js.map +1 -1
- package/lib/node/backend-application.d.ts +9 -0
- package/lib/node/backend-application.d.ts.map +1 -1
- package/lib/node/backend-application.js +23 -1
- package/lib/node/backend-application.js.map +1 -1
- package/lib/node/backend-application.spec.js +1 -0
- package/lib/node/backend-application.spec.js.map +1 -1
- package/lib/node/hosting/backend-hosting-module.d.ts.map +1 -1
- package/lib/node/hosting/backend-hosting-module.js +7 -0
- package/lib/node/hosting/backend-hosting-module.js.map +1 -1
- package/lib/node/hosting/browser-connection-token.d.ts +46 -0
- package/lib/node/hosting/browser-connection-token.d.ts.map +1 -0
- package/lib/node/hosting/browser-connection-token.js +120 -0
- package/lib/node/hosting/browser-connection-token.js.map +1 -0
- package/lib/node/hosting/browser-connection-token.spec.d.ts +2 -0
- package/lib/node/hosting/browser-connection-token.spec.d.ts.map +1 -0
- package/lib/node/hosting/browser-connection-token.spec.js +128 -0
- package/lib/node/hosting/browser-connection-token.spec.js.map +1 -0
- package/lib/node/hosting/ws-origin-validator.d.ts.map +1 -1
- package/lib/node/hosting/ws-origin-validator.js +21 -4
- package/lib/node/hosting/ws-origin-validator.js.map +1 -1
- package/lib/node/hosting/ws-origin-validator.spec.d.ts +2 -0
- package/lib/node/hosting/ws-origin-validator.spec.d.ts.map +1 -0
- package/lib/node/hosting/ws-origin-validator.spec.js +131 -0
- package/lib/node/hosting/ws-origin-validator.spec.js.map +1 -0
- package/lib/node/index.d.ts +1 -0
- package/lib/node/index.d.ts.map +1 -1
- package/lib/node/index.js +3 -1
- package/lib/node/index.js.map +1 -1
- package/lib/node/messaging/websocket-endpoint.d.ts +5 -0
- package/lib/node/messaging/websocket-endpoint.d.ts.map +1 -1
- package/lib/node/messaging/websocket-endpoint.js +14 -5
- package/lib/node/messaging/websocket-endpoint.js.map +1 -1
- package/lib/node/request/backend-request-facade.d.ts +19 -0
- package/lib/node/request/backend-request-facade.d.ts.map +1 -1
- package/lib/node/request/backend-request-facade.js +93 -3
- package/lib/node/request/backend-request-facade.js.map +1 -1
- package/lib/node/request/backend-request-facade.spec.d.ts +2 -0
- package/lib/node/request/backend-request-facade.spec.d.ts.map +1 -0
- package/lib/node/request/backend-request-facade.spec.js +171 -0
- package/lib/node/request/backend-request-facade.spec.js.map +1 -0
- package/package.json +4 -4
- package/src/browser/messaging/ws-connection-source.ts +1 -6
- package/src/browser/tree/tree-view-welcome-widget.spec.ts +80 -0
- package/src/browser/tree/tree-view-welcome-widget.tsx +10 -11
- package/src/electron-node/hosting/electron-ws-origin-validator.spec.ts +69 -0
- package/src/electron-node/hosting/electron-ws-origin-validator.ts +5 -2
- package/src/node/backend-application-module.ts +4 -2
- package/src/node/backend-application.spec.ts +2 -0
- package/src/node/backend-application.ts +18 -0
- package/src/node/hosting/backend-hosting-module.ts +10 -0
- package/src/node/hosting/browser-connection-token.spec.ts +167 -0
- package/src/node/hosting/browser-connection-token.ts +126 -0
- package/src/node/hosting/ws-origin-validator.spec.ts +193 -0
- package/src/node/hosting/ws-origin-validator.ts +23 -4
- package/src/node/index.ts +1 -0
- package/src/node/messaging/websocket-endpoint.ts +17 -5
- package/src/node/request/backend-request-facade.spec.ts +203 -0
- 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
|
-
//
|
|
35
|
-
|
|
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
|
+
}
|