Skip to content

Commit ac5a28c

Browse files
committed
Implement basic authentification
1 parent 5c9fe49 commit ac5a28c

File tree

6 files changed

+225
-30
lines changed

6 files changed

+225
-30
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>vscode-web-server login</title>
6+
</head>
7+
<body>
8+
<form action="/" method="post">
9+
<input style="display: none;" type="text" autocomplete="username" />
10+
<div class="field">
11+
<input required autofocus type="password" placeholder="Enter password..." name="password" autocomplete="current-password" />
12+
<input value="Login" type="submit" />
13+
</div>
14+
</form>
15+
</body>
16+
</html>

src/vs/server/node/args.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import * as path from 'path';
6+
import * as os from 'os';
7+
import { URI } from 'vs/base/common/uri';
8+
import { NativeParsedArgs } from 'vs/platform/environment/common/argv';
9+
import { OptionDescriptions, OPTIONS, parseArgs } from 'vs/platform/environment/node/argv';
10+
import product from 'vs/platform/product/common/product';
11+
12+
export interface ServerParsedArgs extends NativeParsedArgs {
13+
port?: string
14+
password?: string
15+
}
16+
const SERVER_OPTIONS: OptionDescriptions<Required<ServerParsedArgs>> = {
17+
...OPTIONS,
18+
port: { type: 'string' },
19+
password: { type: 'string' }
20+
};
21+
22+
export const devMode = !!process.env['VSCODE_DEV'];
23+
export const args = parseArgs(process.argv, SERVER_OPTIONS);
24+
args['user-data-dir'] = URI.file(path.join(os.homedir(), product.dataFolderName)).fsPath;

src/vs/server/node/auth-http.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import * as path from 'path';
6+
import * as http from 'http';
7+
import { ILogService } from 'vs/platform/log/common/log';
8+
import { parse } from 'querystring';
9+
import { args } from 'vs/server/node/args';
10+
import { authenticated, generateAndSetPassword, handlePasswordValidation } from 'vs/server/node/auth';
11+
import { APP_ROOT, serveFile } from 'vs/server/node/server.main';
12+
13+
const LOGIN = path.join(APP_ROOT, 'out', 'vs', 'server', 'browser', 'workbench', 'login.html');
14+
15+
export async function handleVerification(req: http.IncomingMessage, res: http.ServerResponse | undefined, logService: ILogService): Promise<boolean> {
16+
if (args.password === undefined) {
17+
await generateAndSetPassword(logService);
18+
}
19+
const auth = await authenticated(args, req);
20+
if (!auth && res) {
21+
const password = (await collectRequestData(req)).password;
22+
if (password !== undefined) {
23+
const { valid, hashed } = await handlePasswordValidation({
24+
reqPassword: password,
25+
argsPassword: args.password,
26+
});
27+
28+
if (valid) {
29+
res.setHeader('Set-Cookie', `key=${hashed}; HttpOnly`);
30+
return true;
31+
} else {
32+
serveFile(logService, req, res, LOGIN);
33+
}
34+
} else {
35+
serveFile(logService, req, res, LOGIN);
36+
}
37+
return false;
38+
}
39+
return auth;
40+
}
41+
42+
function collectRequestData(request: http.IncomingMessage): Promise<Record<string, string>> {
43+
return new Promise(resolve => {
44+
const FORM_URLENCODED = 'application/x-www-form-urlencoded';
45+
if (request.headers['content-type'] === FORM_URLENCODED) {
46+
let body = '';
47+
request.on('data', chunk => {
48+
body += chunk.toString();
49+
});
50+
request.on('end', () => {
51+
const item = parse(body) as Record<string, string>;
52+
resolve(item);
53+
});
54+
}
55+
else {
56+
resolve({});
57+
}
58+
});
59+
}

src/vs/server/node/auth.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import * as http from 'http';
6+
import * as crypto from 'crypto';
7+
import { args, ServerParsedArgs } from 'vs/server/node/args';
8+
import { ILogService } from 'vs/platform/log/common/log';
9+
10+
export function sanitizeString(str: string): string {
11+
return typeof str === 'string' && str.trim().length > 0 ? str.trim() : '';
12+
}
13+
14+
export function parseCookies(request: http.IncomingMessage): Record<string, string> {
15+
const cookies: Record<string, string> = {};
16+
const rc = request.headers.cookie;
17+
18+
if (rc) {
19+
rc.split(';').forEach(cookie => {
20+
let parts = cookie.split('=');
21+
if (parts.length > 0) {
22+
const name = parts.shift()!.trim();
23+
let value = decodeURI(parts.join('='));
24+
cookies[name] = value;
25+
}
26+
});
27+
}
28+
29+
return cookies;
30+
}
31+
32+
export async function authenticated(args: ServerParsedArgs, req: http.IncomingMessage): Promise<boolean> {
33+
if (!args.password) {
34+
return true;
35+
}
36+
const cookies = parseCookies(req);
37+
return isHashMatch(args.password || '', sanitizeString(cookies.key));
38+
};
39+
40+
interface PasswordValidation {
41+
valid: boolean
42+
hashed: string
43+
}
44+
45+
interface HandlePasswordValidationArgs {
46+
reqPassword: string | undefined
47+
argsPassword: string | undefined
48+
}
49+
50+
function safeCompare(a: string, b: string): boolean {
51+
return a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
52+
}
53+
54+
export async function generateAndSetPassword(logService: ILogService, length = 24): Promise<void> {
55+
if (args.password || !length) {
56+
return;
57+
}
58+
const password = await generatePassword(length);
59+
args.password = password;
60+
logService.info(`Automatically generated password\r\n ${password}`);
61+
}
62+
63+
export async function generatePassword(length = 24): Promise<string> {
64+
const buffer = Buffer.alloc(Math.ceil(length / 2));
65+
await new Promise(resolve => {
66+
crypto.randomFill(buffer, (_, buf) => resolve(buf));
67+
});
68+
return buffer.toString('hex').substring(0, length);
69+
}
70+
71+
export function hash(str: string): string {
72+
return crypto.createHash('sha256').update(str).digest('hex');
73+
}
74+
75+
export function isHashMatch(password: string, hashPassword: string): boolean {
76+
const hashed = hash(password);
77+
return safeCompare(hashed, hashPassword);
78+
}
79+
80+
export async function handlePasswordValidation({ argsPassword: passwordFromArgs, reqPassword: passwordFromRequestBody }: HandlePasswordValidationArgs): Promise<PasswordValidation> {
81+
if (passwordFromRequestBody) {
82+
const valid = passwordFromArgs ? safeCompare(passwordFromRequestBody, passwordFromArgs) : false;
83+
const hashed = hash(passwordFromRequestBody);
84+
return {
85+
valid,
86+
hashed
87+
};
88+
}
89+
90+
return {
91+
valid: false,
92+
hashed: ''
93+
}
94+
}

src/vs/server/node/server.main.ts

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import * as crypto from 'crypto';
77
import * as fs from 'fs';
88
import * as http from 'http';
99
import * as net from 'net';
10-
import * as os from 'os';
1110
import * as path from 'path';
1211
import * as url from 'url';
1312
import { RunOnceScheduler } from 'vs/base/common/async';
@@ -33,9 +32,7 @@ import { ConfigurationService } from 'vs/platform/configuration/common/configura
3332
import { ExtensionHostDebugBroadcastChannel } from 'vs/platform/debug/common/extensionHostDebugIpc';
3433
import { IDownloadService } from 'vs/platform/download/common/download';
3534
import { DownloadService } from 'vs/platform/download/common/downloadService';
36-
import { NativeParsedArgs } from 'vs/platform/environment/common/argv';
3735
import { IEnvironmentService, INativeEnvironmentService } from 'vs/platform/environment/common/environment';
38-
import { OptionDescriptions, OPTIONS, parseArgs } from 'vs/platform/environment/node/argv';
3936
import { NativeEnvironmentService } from 'vs/platform/environment/node/environmentService';
4037
import { ExtensionGalleryService } from 'vs/platform/extensionManagement/common/extensionGalleryService';
4138
import { IExtensionGalleryService, IExtensionManagementService } from 'vs/platform/extensionManagement/common/extensionManagement';
@@ -62,6 +59,7 @@ import { RequestChannel } from 'vs/platform/request/common/requestIpc';
6259
import { RequestService } from 'vs/platform/request/node/requestService';
6360
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
6461
import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils';
62+
import { args } from 'vs/server/node/args';
6563
import { IFileChangeDto } from 'vs/workbench/api/common/extHost.protocol';
6664
import { IExtHostReadyMessage, IExtHostSocketMessage } from 'vs/workbench/services/extensions/common/extensionHostProtocol';
6765
import { Logger } from 'vs/workbench/services/extensions/common/extensionPoints';
@@ -73,7 +71,7 @@ import { RemoteExtensionLogFileName } from 'vs/workbench/services/remote/common/
7371
export type IRawURITransformerFactory = (remoteAuthority: string) => IRawURITransformer;
7472
export const IRawURITransformerFactory = createDecorator<IRawURITransformerFactory>('rawURITransformerFactory');
7573

76-
const APP_ROOT = path.join(__dirname, '..', '..', '..', '..');
74+
export const APP_ROOT = path.join(__dirname, '..', '..', '..', '..');
7775
const uriTransformerPath = path.join(APP_ROOT, 'out/serverUriTransformer');
7876
const rawURITransformerFactory: IRawURITransformerFactory = <any>require.__$__nodeRequire(uriTransformerPath);
7977

@@ -169,7 +167,7 @@ function getMediaMime(forPath: string): string | undefined {
169167
return mapExtToMediaMimes.get(ext.toLowerCase());
170168
}
171169

172-
async function serveFile(logService: ILogService, req: http.IncomingMessage, res: http.ServerResponse, filePath: string, responseHeaders: http.OutgoingHttpHeaders = {}) {
170+
export async function serveFile(logService: ILogService, req: http.IncomingMessage, res: http.ServerResponse, filePath: string, responseHeaders: http.OutgoingHttpHeaders = {}) {
173171
try {
174172

175173
// Sanity checks
@@ -199,19 +197,11 @@ async function serveFile(logService: ILogService, req: http.IncomingMessage, res
199197
}
200198
}
201199

202-
function serveError(req: http.IncomingMessage, res: http.ServerResponse, errorCode: number, errorMessage: string): void {
200+
export function serveError(req: http.IncomingMessage, res: http.ServerResponse, errorCode: number, errorMessage: string): void {
203201
res.writeHead(errorCode, { 'Content-Type': 'text/plain' });
204202
res.end(errorMessage);
205203
}
206204

207-
interface ServerParsedArgs extends NativeParsedArgs {
208-
port?: string
209-
}
210-
const SERVER_OPTIONS: OptionDescriptions<Required<ServerParsedArgs>> = {
211-
...OPTIONS,
212-
port: { type: 'string' }
213-
};
214-
215205
export interface IStartServerResult {
216206
installingInitialExtensions?: Promise<void>
217207
}
@@ -227,17 +217,16 @@ export interface IServerOptions {
227217
configureExtensionHostForkOptions?(opts: cp.ForkOptions, accessor: ServicesAccessor, channelServer: IPCServer<RemoteAgentConnectionContext>): void;
228218
configureExtensionHostProcess?(extensionHost: cp.ChildProcess, accessor: ServicesAccessor, channelServer: IPCServer<RemoteAgentConnectionContext>): IDisposable;
229219

220+
verifyRequest?(req: http.IncomingMessage, res: http.ServerResponse | undefined, accessor: ServicesAccessor): Promise<boolean>;
230221
handleRequest?(pathname: string | null, req: http.IncomingMessage, res: http.ServerResponse, accessor: ServicesAccessor, channelServer: IPCServer<RemoteAgentConnectionContext>): Promise<boolean>;
231222
}
232223

233224
export async function main(options: IServerOptions): Promise<void> {
234225
const devMode = !!process.env['VSCODE_DEV'];
235226
const connectionToken = generateUuid();
236227

237-
const parsedArgs = parseArgs(process.argv, SERVER_OPTIONS);
238-
parsedArgs['user-data-dir'] = URI.file(path.join(os.homedir(), product.dataFolderName)).fsPath;
239228
const productService = { _serviceBrand: undefined, ...product };
240-
const environmentService = new NativeEnvironmentService(parsedArgs, productService);
229+
const environmentService = new NativeEnvironmentService(args, productService);
241230

242231
// see src/vs/code/electron-main/main.ts#142
243232
const bufferLogService = new BufferLogService();
@@ -571,10 +560,12 @@ export async function main(options: IServerOptions): Promise<void> {
571560
const parsedUrl = url.parse(req.url, true);
572561
const pathname = parsedUrl.pathname;
573562

563+
if (options.verifyRequest && !await instantiationService.invokeFunction(accessor => options.verifyRequest!(req, res, accessor))) {
564+
return;
565+
}
574566
if (options.handleRequest && await instantiationService.invokeFunction(accessor => options.handleRequest!(pathname, req, res, accessor, channelServer))) {
575567
return;
576568
}
577-
578569
//#region headless
579570
if (pathname === '/vscode-remote-resource') {
580571
const filePath = parsedUrl.query['path'];
@@ -621,12 +612,16 @@ export async function main(options: IServerOptions): Promise<void> {
621612
}
622613
});
623614
server.on('error', e => logService.error(e));
624-
server.on('upgrade', (req: http.IncomingMessage, socket: net.Socket) => {
615+
server.on('upgrade', async (req: http.IncomingMessage, socket: net.Socket) => {
625616
if (req.headers['upgrade'] !== 'websocket' || !req.url) {
626617
logService.error(`failed to upgrade for header "${req.headers['upgrade']}" and url: "${req.url}".`);
627618
socket.end('HTTP/1.1 400 Bad Request');
628619
return;
629620
}
621+
if (options.verifyRequest && !await instantiationService.invokeFunction(accessor => options.verifyRequest!(req, undefined, accessor))) {
622+
socket.end('HTTP/1.1 401 Unauthorized');
623+
return;
624+
}
630625
const { query } = url.parse(req.url, true);
631626
// /?reconnectionToken=c0e3a8af-6838-44fb-851b-675401030831&reconnection=false&skipWebSocketFrames=false
632627
const reconnection = 'reconnection' in query && query['reconnection'] === 'true';
@@ -736,7 +731,7 @@ export async function main(options: IServerOptions): Promise<void> {
736731
logService.info(`[${token}] Management connection is connected.`);
737732
} else {
738733
if (!client.management) {
739-
logService.error(`[${token}] Failed to reconnect: management connection is not running.`);
734+
logService.error(`[${token}] Failed to reconnect: management connection is not running.`);
740735
protocol.sendControl(VSBuffer.fromString(JSON.stringify({ type: 'error', reason: 'Management connection is not running.' } as ErrorMessage)));
741736
safeDisposeProtocolAndSocket(protocol);
742737
return;
@@ -826,17 +821,17 @@ export async function main(options: IServerOptions): Promise<void> {
826821
socket.end();
827822
extensionHost.kill();
828823
client.extensionHost = undefined;
829-
logService.info(`[${token}] Extension host is disconnected.`);
824+
logService.info(`[${token}] Extension host is disconnected.`);
830825
}
831826

832827
extensionHost.on('error', err => {
833828
dispose();
834-
logService.error(`[${token}] Extension host failed with: `, err);
829+
logService.error(`[${token}] Extension host failed with: `, err);
835830
});
836831
extensionHost.on('exit', (code: number, signal: string) => {
837832
dispose();
838833
if (code !== 0 && signal !== 'SIGTERM') {
839-
logService.error(`[${token}] Extension host exited with code: ${code} and signal: ${signal}.`);
834+
logService.error(`[${token}] Extension host exited with code: ${code} and signal: ${signal}.`);
840835
}
841836
});
842837

@@ -851,7 +846,7 @@ export async function main(options: IServerOptions): Promise<void> {
851846
permessageDeflate,
852847
inflateBytes
853848
} as IExtHostSocketMessage, socket);
854-
logService.info(`[${token}] Extension host is connected.`);
849+
logService.info(`[${token}] Extension host is connected.`);
855850
}
856851
};
857852
extensionHost.on('message', readyListener);
@@ -860,13 +855,13 @@ export async function main(options: IServerOptions): Promise<void> {
860855
toDispose = instantiationService.invokeFunction(accessor => options.configureExtensionHostProcess!(extensionHost, accessor, channelServer));
861856
}
862857
client.extensionHost = extensionHost;
863-
logService.info(`[${token}] Extension host is started.`);
858+
logService.info(`[${token}] Extension host is started.`);
864859
} catch (e) {
865-
logService.error(`[${token}] Failed to start the extension host process: `, e);
860+
logService.error(`[${token}] Failed to start the extension host process: `, e);
866861
}
867862
} else {
868863
if (!client.extensionHost) {
869-
logService.error(`[${token}] Failed to reconnect: extension host is not running.`);
864+
logService.error(`[${token}] Failed to reconnect: extension host is not running.`);
870865
protocol.sendControl(VSBuffer.fromString(JSON.stringify({ type: 'error', reason: 'Extension host is not running.' } as ErrorMessage)));
871866
safeDisposeProtocolAndSocket(protocol);
872867
return;
@@ -886,7 +881,7 @@ export async function main(options: IServerOptions): Promise<void> {
886881
permessageDeflate,
887882
inflateBytes
888883
} as IExtHostSocketMessage, socket);
889-
logService.info(`[${token}] Extension host is reconnected.`);
884+
logService.info(`[${token}] Extension host is reconnected.`);
890885
}
891886
} else {
892887
logService.error(`[${token}] Unexpected connection type:`, msg.desiredConnectionType);
@@ -899,8 +894,8 @@ export async function main(options: IServerOptions): Promise<void> {
899894
});
900895
});
901896
let port = 3000;
902-
if (parsedArgs.port) {
903-
port = Number(parsedArgs.port);
897+
if (args.port) {
898+
port = Number(args.port);
904899
} else if (typeof options.port === 'number') {
905900
port = options.port;
906901
}

0 commit comments

Comments
 (0)