Skip to content

Commit 524e7b0

Browse files
Warn on insecure remote (microsoft#13060)
1 parent c95971d commit 524e7b0

File tree

6 files changed

+175
-6
lines changed

6 files changed

+175
-6
lines changed

news/1 Enhancements/12980.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Warn users if they are connecting over http without a token.

package.nls.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
"DataScience.launchNotebookTrustPrompt.yes": "Trust",
3737
"DataScience.launchNotebookTrustPrompt.no": "Do not trust",
3838
"DataScience.launchNotebookTrustPrompt.trustAllNotebooks": "Trust all notebooks",
39+
"DataScience.insecureSessionMessage": "Connecting over HTTP without a token may be an insecure connection. Do you want to connect to a possibly insecure server?",
40+
"DataScience.insecureSessionDenied": "Denied connection to insecure server.",
3941
"python.command.python.viewLanguageServerOutput.title": "Show Language Server Output",
4042
"python.command.python.selectAndRunTestMethod.title": "Run Test Method ...",
4143
"python.command.python.selectAndDebugTestMethod.title": "Debug Test Method ...",

src/client/common/utils/localize.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,14 @@ export namespace DataScience {
10551055
'DataScience.launchNotebookTrustPrompt.trustAllNotebooks',
10561056
'Trust all notebooks'
10571057
);
1058+
export const insecureSessionMessage = localize(
1059+
'DataScience.insecureSessionMessage',
1060+
'Connecting over HTTP without a token may be an insecure connection. Do you want to connect to a possibly insecure server?'
1061+
);
1062+
export const insecureSessionDenied = localize(
1063+
'DataScience.insecureSessionDenied',
1064+
'Denied connection to insecure server.'
1065+
);
10581066
export const previewNotebookOnlySupportedInVSCInsiders = localize(
10591067
'DataScience.previewNotebookOnlySupportedInVSCInsiders',
10601068
'The Preview Notebook Editor is supported only in the Insiders version of Visual Studio Code.'

src/client/datascience/jupyter/jupyterSessionManager.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import type { ContentsManager, ServerConnection, Session, SessionManager } from
55
import { Agent as HttpsAgent } from 'https';
66
import * as nodeFetch from 'node-fetch';
77
import { CancellationToken } from 'vscode-jsonrpc';
8+
import { IApplicationShell } from '../../common/application/types';
89

910
import { traceError, traceInfo } from '../../common/logger';
10-
import { IConfigurationService, IOutputChannel } from '../../common/types';
11+
import { IConfigurationService, IOutputChannel, IPersistentState, IPersistentStateFactory } from '../../common/types';
1112
import { sleep } from '../../common/utils/async';
1213
import * as localize from '../../common/utils/localize';
1314
import { noop } from '../../common/utils/misc';
@@ -27,14 +28,19 @@ import { JupyterKernelSpec } from './kernels/jupyterKernelSpec';
2728
import { KernelSelector } from './kernels/kernelSelector';
2829
import { LiveKernelModel } from './kernels/types';
2930

31+
// Key for our insecure connection global state
32+
const GlobalStateUserAllowsInsecureConnections = 'DataScienceAllowInsecureConnections';
33+
3034
// tslint:disable: no-any
3135

3236
export class JupyterSessionManager implements IJupyterSessionManager {
37+
private static secureServers = new Map<string, Promise<boolean>>();
3338
private sessionManager: SessionManager | undefined;
3439
private contentsManager: ContentsManager | undefined;
3540
private connInfo: IJupyterConnection | undefined;
3641
private serverSettings: ServerConnection.ISettings | undefined;
3742
private _jupyterlab?: typeof import('@jupyterlab/services');
43+
private readonly userAllowsInsecureConnections: IPersistentState<boolean>;
3844
private get jupyterlab(): typeof import('@jupyterlab/services') {
3945
if (!this._jupyterlab) {
4046
// tslint:disable-next-line: no-require-imports
@@ -48,8 +54,15 @@ export class JupyterSessionManager implements IJupyterSessionManager {
4854
private failOnPassword: boolean | undefined,
4955
private kernelSelector: KernelSelector,
5056
private outputChannel: IOutputChannel,
51-
private configService: IConfigurationService
52-
) {}
57+
private configService: IConfigurationService,
58+
private readonly appShell: IApplicationShell,
59+
private readonly stateFactory: IPersistentStateFactory
60+
) {
61+
this.userAllowsInsecureConnections = this.stateFactory.createGlobalPersistentState<boolean>(
62+
GlobalStateUserAllowsInsecureConnections,
63+
false
64+
);
65+
}
5366

5467
public async dispose() {
5568
traceInfo(`Disposing session manager`);
@@ -229,6 +242,9 @@ export class JupyterSessionManager implements IJupyterSessionManager {
229242
wsUrl: connInfo.baseUrl.replace('http', 'ws')
230243
};
231244

245+
// Before we connect, see if we are trying to make an insecure connection, if we are, warn the user
246+
await this.secureConnectionCheck(connInfo);
247+
232248
// Agent is allowed to be set on this object, but ts doesn't like it on RequestInit, so any
233249
// tslint:disable-next-line:no-any
234250
let requestInit: any = { cache: 'no-store', credentials: 'same-origin' };
@@ -305,4 +321,58 @@ export class JupyterSessionManager implements IJupyterSessionManager {
305321
traceInfo(`Creating server with settings : ${JSON.stringify(serverSettings)}`);
306322
return this.jupyterlab.ServerConnection.makeSettings(serverSettings);
307323
}
324+
325+
// If connecting on HTTP without a token prompt the user that this connection may not be secure
326+
private async insecureServerWarningPrompt(): Promise<boolean> {
327+
const insecureMessage = localize.DataScience.insecureSessionMessage();
328+
const insecureLabels = [
329+
localize.Common.bannerLabelYes(),
330+
localize.Common.bannerLabelNo(),
331+
localize.Common.doNotShowAgain()
332+
];
333+
const response = await this.appShell.showWarningMessage(insecureMessage, ...insecureLabels);
334+
335+
switch (response) {
336+
case localize.Common.bannerLabelYes():
337+
// On yes just proceed as normal
338+
return true;
339+
340+
case localize.Common.doNotShowAgain():
341+
// For don't ask again turn on the global true
342+
await this.userAllowsInsecureConnections.updateValue(true);
343+
return true;
344+
345+
case localize.Common.bannerLabelNo():
346+
default:
347+
// No or for no choice return back false to block
348+
return false;
349+
}
350+
}
351+
352+
// Check if our server connection is considered secure. If it is not, ask the user if they want to connect
353+
// If not, throw to bail out on the process
354+
private async secureConnectionCheck(connInfo: IJupyterConnection): Promise<void> {
355+
// If they have turned on global server trust then everything is secure
356+
if (this.userAllowsInsecureConnections.value) {
357+
return;
358+
}
359+
360+
// If they are local launch, https, or have a token, then they are secure
361+
if (connInfo.localLaunch || connInfo.baseUrl.startsWith('https') || connInfo.token !== 'null') {
362+
return;
363+
}
364+
365+
// At this point prompt the user, cache the promise so we don't ask multiple times for the same server
366+
let serverSecurePromise = JupyterSessionManager.secureServers.get(connInfo.baseUrl);
367+
368+
if (serverSecurePromise === undefined) {
369+
serverSecurePromise = this.insecureServerWarningPrompt();
370+
JupyterSessionManager.secureServers.set(connInfo.baseUrl, serverSecurePromise);
371+
}
372+
373+
// If our server is not secure, throw here to bail out on the process
374+
if (!(await serverSecurePromise)) {
375+
throw new Error(localize.DataScience.insecureSessionDenied());
376+
}
377+
}
308378
}

src/client/datascience/jupyter/jupyterSessionManagerFactory.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
// Licensed under the MIT License.
33
'use strict';
44
import { inject, injectable, named } from 'inversify';
5+
import { IApplicationShell } from '../../common/application/types';
56

6-
import { IConfigurationService, IOutputChannel } from '../../common/types';
7+
import { IConfigurationService, IOutputChannel, IPersistentStateFactory } from '../../common/types';
78
import { JUPYTER_OUTPUT_CHANNEL } from '../constants';
89
import {
910
IJupyterConnection,
@@ -20,7 +21,9 @@ export class JupyterSessionManagerFactory implements IJupyterSessionManagerFacto
2021
@inject(IJupyterPasswordConnect) private jupyterPasswordConnect: IJupyterPasswordConnect,
2122
@inject(IConfigurationService) private config: IConfigurationService,
2223
@inject(KernelSelector) private kernelSelector: KernelSelector,
23-
@inject(IOutputChannel) @named(JUPYTER_OUTPUT_CHANNEL) private jupyterOutput: IOutputChannel
24+
@inject(IOutputChannel) @named(JUPYTER_OUTPUT_CHANNEL) private jupyterOutput: IOutputChannel,
25+
@inject(IApplicationShell) private readonly appShell: IApplicationShell,
26+
@inject(IPersistentStateFactory) private readonly stateFactory: IPersistentStateFactory
2427
) {}
2528

2629
/**
@@ -35,7 +38,9 @@ export class JupyterSessionManagerFactory implements IJupyterSessionManagerFacto
3538
failOnPassword,
3639
this.kernelSelector,
3740
this.jupyterOutput,
38-
this.config
41+
this.config,
42+
this.appShell,
43+
this.stateFactory
3944
);
4045
await result.initialize(connInfo);
4146
return result;

src/test/datascience/notebook.functional.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,12 +520,95 @@ suite('DataScience notebook tests', () => {
520520
)
521521
)
522522
.returns((_a1: string, a2: string, _a3: string, _a4: string) => Promise.resolve(a2));
523+
appShell
524+
.setup((a) =>
525+
a.showWarningMessage(
526+
TypeMoq.It.isAny(),
527+
TypeMoq.It.isAny(),
528+
TypeMoq.It.isAny(),
529+
TypeMoq.It.isAny()
530+
)
531+
)
532+
.returns((_a1: string, a2: string, _a3: string, _a4: string) => Promise.resolve(a2));
523533
appShell.setup((a) => a.showInputBox(TypeMoq.It.isAny())).returns(() => Promise.resolve(''));
524534
appShell.setup((a) => a.setStatusBarMessage(TypeMoq.It.isAny())).returns(() => dummyDisposable);
525535
ioc.serviceManager.rebindInstance<IApplicationShell>(IApplicationShell, appShell.object);
526536
}
527537
);
528538

539+
// For a connection to a remote machine that is not secure deny the connection and we should not connect
540+
runTest(
541+
'Remote Deny Insecure',
542+
async () => {
543+
const pythonService = await createPythonService();
544+
545+
if (pythonService) {
546+
const configFile = path.join(
547+
EXTENSION_ROOT_DIR,
548+
'src',
549+
'test',
550+
'datascience',
551+
'serverConfigFiles',
552+
'remoteNoAuth.py'
553+
);
554+
const uri = await startRemoteServer(pythonService, [
555+
'-m',
556+
'jupyter',
557+
'notebook',
558+
`--config=${configFile}`
559+
]);
560+
561+
// Try to create, we expect a failure here as we will deny the insecure connection
562+
await createNotebook(uri, undefined, true);
563+
}
564+
},
565+
undefined,
566+
() => {
567+
const dummyDisposable = {
568+
dispose: () => {
569+
return;
570+
}
571+
};
572+
const appShell = TypeMoq.Mock.ofType<IApplicationShell>();
573+
appShell
574+
.setup((a) => a.showErrorMessage(TypeMoq.It.isAnyString()))
575+
.returns((e) => {
576+
throw e;
577+
});
578+
appShell
579+
.setup((a) => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
580+
.returns(() => Promise.resolve(''));
581+
appShell
582+
.setup((a) =>
583+
a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())
584+
)
585+
.returns((_a1: string, a2: string, _a3: string) => Promise.resolve(a2));
586+
appShell
587+
.setup((a) =>
588+
a.showInformationMessage(
589+
TypeMoq.It.isAny(),
590+
TypeMoq.It.isAny(),
591+
TypeMoq.It.isAny(),
592+
TypeMoq.It.isAny()
593+
)
594+
)
595+
.returns((_a1: string, a2: string, _a3: string, _a4: string) => Promise.resolve(a2));
596+
// This is the call to app shell that we are changing from the above test
597+
appShell
598+
.setup((a) =>
599+
a.showWarningMessage(
600+
TypeMoq.It.isAny(),
601+
TypeMoq.It.isAny(),
602+
TypeMoq.It.isAny(),
603+
TypeMoq.It.isAny()
604+
)
605+
)
606+
.returns((_a1: string, _a2: string, a3: string, _a4: string) => Promise.resolve(a3));
607+
appShell.setup((a) => a.showInputBox(TypeMoq.It.isAny())).returns(() => Promise.resolve(''));
608+
appShell.setup((a) => a.setStatusBarMessage(TypeMoq.It.isAny())).returns(() => dummyDisposable);
609+
ioc.serviceManager.rebindInstance<IApplicationShell>(IApplicationShell, appShell.object);
610+
}
611+
);
529612
runTest('Remote Password', async () => {
530613
const pythonService = await createPythonService();
531614

0 commit comments

Comments
 (0)