Skip to content

Commit e5880a8

Browse files
committed
Separate concerns of http server
1 parent 8135b07 commit e5880a8

File tree

7 files changed

+1217
-788
lines changed

7 files changed

+1217
-788
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"argon2": "^0.28.2",
6363
"applicationinsights": "1.0.8",
6464
"chokidar": "3.5.1",
65+
"express": "^4.17.1",
6566
"graceful-fs": "4.2.6",
6667
"http-proxy-agent": "^2.1.0",
6768
"https-proxy-agent": "^2.2.3",

src/vs/server/node/args.ts

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

src/vs/server/node/auth.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import * as http from 'http';
2+
import * as crypto from 'crypto';
3+
import * as argon2 from 'argon2';
4+
import { ServerParsedArgs } from 'vs/server/node/args';
5+
import { serveError } from 'vs/server/node/http';
6+
7+
/** Ensures that the input is sanitized by checking
8+
* - it's a string
9+
* - greater than 0 characters
10+
* - trims whitespace
11+
*/
12+
export function sanitizeString(str: string): string {
13+
// Very basic sanitization of string
14+
// Credit: https://stackoverflow.com/a/46719000/3015595
15+
return typeof str === 'string' && str.trim().length > 0 ? str.trim() : '';
16+
}
17+
18+
export const ensureAuthenticated = async (args: ServerParsedArgs, req: http.IncomingMessage, res: http.ServerResponse): Promise<boolean> => {
19+
const isAuthenticated = await authenticated(args, req);
20+
if (!isAuthenticated) {
21+
serveError(req, res, 401, 'Unauthorized');
22+
}
23+
return isAuthenticated;
24+
};
25+
26+
/**
27+
* Return true if authenticated via cookies.
28+
*/
29+
export const authenticated = async (args: ServerParsedArgs, req: http.IncomingMessage): Promise<boolean> => {
30+
if (!args.password && !args.hashedPassword) {
31+
return true;
32+
}
33+
const passwordMethod = getPasswordMethod(args.hashedPassword);
34+
const cookies = parseCookies(req);
35+
const isCookieValidArgs: IsCookieValidArgs = {
36+
passwordMethod,
37+
cookieKey: sanitizeString(cookies.key),
38+
passwordFromArgs: args.password || '',
39+
hashedPasswordFromArgs: args.hashedPassword,
40+
};
41+
42+
return await isCookieValid(isCookieValidArgs);
43+
};
44+
45+
function parseCookies(request: http.IncomingMessage): Record<string, string> {
46+
const cookies: Record<string, string> = {},
47+
rc = request.headers.cookie;
48+
49+
// eslint-disable-next-line code-no-unused-expressions
50+
rc && rc.split(';').forEach(cookie => {
51+
let parts = cookie.split('=');
52+
if (parts.length > 0) {
53+
const name = parts.shift()!.trim();
54+
let value = decodeURI(parts.join('='));
55+
value = value.substring(1, value.length - 1);
56+
cookies[name] = value;
57+
}
58+
});
59+
60+
return cookies;
61+
}
62+
63+
export type PasswordMethod = 'ARGON2' | 'PLAIN_TEXT';
64+
65+
/**
66+
* Used to determine the password method.
67+
*
68+
* There are three options for the return value:
69+
* 1. "SHA256" -> the legacy hashing algorithm
70+
* 2. "ARGON2" -> the newest hashing algorithm
71+
* 3. "PLAIN_TEXT" -> regular ol' password with no hashing
72+
*
73+
* @returns "ARGON2" | "PLAIN_TEXT"
74+
*/
75+
export function getPasswordMethod(hashedPassword: string | undefined): PasswordMethod {
76+
if (!hashedPassword) {
77+
return 'PLAIN_TEXT';
78+
}
79+
return 'ARGON2';
80+
}
81+
82+
type PasswordValidation = {
83+
isPasswordValid: boolean
84+
hashedPassword: string
85+
};
86+
87+
type HandlePasswordValidationArgs = {
88+
/** The PasswordMethod */
89+
passwordMethod: PasswordMethod
90+
/** The password provided by the user */
91+
passwordFromRequestBody: string
92+
/** The password set in PASSWORD or config */
93+
passwordFromArgs: string | undefined
94+
/** The hashed-password set in HASHED_PASSWORD or config */
95+
hashedPasswordFromArgs: string | undefined
96+
};
97+
98+
function safeCompare(a: string, b: string): boolean {
99+
if (b.length > a.length) {
100+
a = a.padEnd(b.length);
101+
}
102+
if (a.length > b.length) {
103+
b = b.padEnd(a.length);
104+
}
105+
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
106+
}
107+
108+
export const generatePassword = async (length = 24): Promise<string> => {
109+
const buffer = Buffer.alloc(Math.ceil(length / 2));
110+
await new Promise(resolve => {
111+
crypto.randomFill(buffer, (_, buf) => resolve(buf));
112+
});
113+
return buffer.toString('hex').substring(0, length);
114+
};
115+
116+
/**
117+
* Used to hash the password.
118+
*/
119+
export const hash = async (password: string): Promise<string> => {
120+
try {
121+
return await argon2.hash(password);
122+
} catch (error) {
123+
console.error(error);
124+
return '';
125+
}
126+
};
127+
128+
/**
129+
* Used to verify if the password matches the hash
130+
*/
131+
export const isHashMatch = async (password: string, hash: string) => {
132+
if (password === '' || hash === '' || !hash.startsWith('$')) {
133+
return false;
134+
}
135+
try {
136+
return await argon2.verify(hash, password);
137+
} catch (error) {
138+
throw new Error(error);
139+
}
140+
};
141+
142+
/**
143+
* Used to hash the password using the sha256
144+
* algorithm. We only use this to for checking
145+
* the hashed-password set in the config.
146+
*
147+
* Kept for legacy reasons.
148+
*/
149+
export const hashLegacy = (str: string): string => {
150+
return crypto.createHash('sha256').update(str).digest('hex');
151+
};
152+
153+
/**
154+
* Used to check if the password matches the hash using
155+
* the hashLegacy function
156+
*/
157+
export const isHashLegacyMatch = (password: string, hashPassword: string) => {
158+
const hashedWithLegacy = hashLegacy(password);
159+
return safeCompare(hashedWithLegacy, hashPassword);
160+
};
161+
162+
/**
163+
* Checks if a password is valid and also returns the hash
164+
* using the PasswordMethod
165+
*/
166+
export async function handlePasswordValidation({
167+
passwordMethod,
168+
passwordFromArgs,
169+
passwordFromRequestBody,
170+
hashedPasswordFromArgs,
171+
}: HandlePasswordValidationArgs): Promise<PasswordValidation> {
172+
const passwordValidation: PasswordValidation = {
173+
isPasswordValid: false,
174+
hashedPassword: '',
175+
};
176+
177+
switch (passwordMethod) {
178+
case 'PLAIN_TEXT': {
179+
const isValid = passwordFromArgs ? safeCompare(passwordFromRequestBody, passwordFromArgs) : false;
180+
passwordValidation.isPasswordValid = isValid;
181+
182+
const hashedPassword = await hash(passwordFromRequestBody);
183+
passwordValidation.hashedPassword = hashedPassword;
184+
break;
185+
}
186+
case 'ARGON2': {
187+
const isValid = await isHashMatch(passwordFromRequestBody, hashedPasswordFromArgs || '');
188+
passwordValidation.isPasswordValid = isValid;
189+
190+
passwordValidation.hashedPassword = hashedPasswordFromArgs || '';
191+
break;
192+
}
193+
default:
194+
break;
195+
}
196+
197+
return passwordValidation;
198+
}
199+
200+
export type IsCookieValidArgs = {
201+
passwordMethod: PasswordMethod
202+
cookieKey: string
203+
hashedPasswordFromArgs: string | undefined
204+
passwordFromArgs: string | undefined
205+
};
206+
207+
/** Checks if a req.cookies.key is valid using the PasswordMethod */
208+
export async function isCookieValid({
209+
passwordFromArgs = '',
210+
cookieKey,
211+
hashedPasswordFromArgs = '',
212+
passwordMethod,
213+
}: IsCookieValidArgs): Promise<boolean> {
214+
let isValid = false;
215+
switch (passwordMethod) {
216+
case 'PLAIN_TEXT':
217+
isValid = await isHashMatch(passwordFromArgs, cookieKey);
218+
break;
219+
case 'ARGON2':
220+
isValid = safeCompare(cookieKey, hashedPasswordFromArgs);
221+
break;
222+
default:
223+
break;
224+
}
225+
return isValid;
226+
}

src/vs/server/node/http.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as http from 'http';
4+
import { ILogService } from 'vs/platform/log/common/log';
5+
import { parse } from 'querystring';
6+
7+
// TODO is it enough?
8+
const textMimeType = new Map([
9+
['.html', 'text/html'],
10+
['.js', 'text/javascript'],
11+
['.json', 'application/json'],
12+
['.css', 'text/css'],
13+
['.svg', 'image/svg+xml']
14+
]);
15+
16+
// TODO is it enough?
17+
const mapExtToMediaMimes = new Map([
18+
['.bmp', 'image/bmp'],
19+
['.gif', 'image/gif'],
20+
['.ico', 'image/x-icon'],
21+
['.jpe', 'image/jpg'],
22+
['.jpeg', 'image/jpg'],
23+
['.jpg', 'image/jpg'],
24+
['.png', 'image/png'],
25+
['.tga', 'image/x-tga'],
26+
['.tif', 'image/tiff'],
27+
['.tiff', 'image/tiff'],
28+
['.woff', 'application/font-woff']
29+
]);
30+
31+
export function getMediaMime(forPath: string): string | undefined {
32+
const ext = path.extname(forPath);
33+
return mapExtToMediaMimes.get(ext.toLowerCase());
34+
}
35+
36+
export function collectRequestData(request: http.IncomingMessage): Promise<Record<string, string>> {
37+
return new Promise(resolve => {
38+
const FORM_URLENCODED = 'application/x-www-form-urlencoded';
39+
if (request.headers['content-type'] === FORM_URLENCODED) {
40+
let body = '';
41+
request.on('data', chunk => {
42+
body += chunk.toString();
43+
});
44+
request.on('end', () => {
45+
const item = parse(body) as Record<string, string>;
46+
resolve(item);
47+
});
48+
}
49+
else {
50+
resolve({});
51+
}
52+
});
53+
}
54+
55+
export async function serveFile(logService: ILogService, req: http.IncomingMessage, res: http.ServerResponse, filePath: string, responseHeaders: http.OutgoingHttpHeaders = {}) {
56+
try {
57+
58+
// Sanity checks
59+
filePath = path.normalize(filePath); // ensure no "." and ".."
60+
61+
const stat = await fs.promises.stat(filePath);
62+
63+
// Check if file modified since
64+
const etag = `W/"${[stat.ino, stat.size, stat.mtime.getTime()].join('-')}"`; // weak validator (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag)
65+
if (req.headers['if-none-match'] === etag) {
66+
res.writeHead(304);
67+
return res.end();
68+
}
69+
70+
// Headers
71+
responseHeaders['Content-Type'] = textMimeType.get(path.extname(filePath)) || getMediaMime(filePath) || 'text/plain';
72+
responseHeaders['Etag'] = etag;
73+
74+
res.writeHead(200, responseHeaders);
75+
76+
// Data
77+
fs.createReadStream(filePath).pipe(res);
78+
} catch (error) {
79+
logService.error(error.toString());
80+
res.writeHead(404, { 'Content-Type': 'text/plain' });
81+
return res.end('Not found');
82+
}
83+
}
84+
85+
export function serveError(req: http.IncomingMessage, res: http.ServerResponse, errorCode: number, errorMessage: string): void {
86+
res.writeHead(errorCode, { 'Content-Type': 'text/plain' });
87+
res.end(errorMessage);
88+
}

0 commit comments

Comments
 (0)