|
| 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 | +} |
0 commit comments