Skip to content

Commit 717eaa6

Browse files
authoredJun 9, 2021
Merge pull request #3422 from cdr/jsjoeio/fix-password-hash
fix: use sufficient computational effort for password hash
2 parents d8c3ba6 + 1e55a64 commit 717eaa6

21 files changed

+1083
-69
lines changed
 

‎CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ VS Code v0.00.0
5959
- chore: cross-compile docker images with buildx #3166 @oxy
6060
- chore: update node to v14 #3458 @oxy
6161
- chore: update .gitignore #3557 @cuining
62+
- fix: use sufficient computational effort for password hash #3422 @jsjoeio
6263

6364
### Development
6465

‎ci/build/build-standalone-release.sh

+4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4+
# This is due to an upstream issue with RHEL7/CentOS 7 comptability with node-argon2
5+
# See: https://github.com/cdr/code-server/pull/3422#pullrequestreview-677765057
6+
export npm_config_build_from_source=true
7+
48
main() {
59
cd "$(dirname "${0}")/../.."
610
source ./ci/lib.sh

‎ci/build/npm-postinstall.sh

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ detect_arch() {
1818
}
1919

2020
ARCH="${NPM_CONFIG_ARCH:-$(detect_arch)}"
21+
# This is due to an upstream issue with RHEL7/CentOS 7 comptability with node-argon2
22+
# See: https://github.com/cdr/code-server/pull/3422#pullrequestreview-677765057
23+
export npm_config_build_from_source=true
2124

2225
main() {
2326
# Grabs the major version of node from $npm_config_user_agent which looks like

‎docs/FAQ.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -205,17 +205,18 @@ Again, please follow [./guide.md](./guide.md) for our recommendations on setting
205205

206206
Yes you can! Set the value of `hashed-password` instead of `password`. Generate the hash with:
207207

208-
```
209-
printf "thisismypassword" | sha256sum | cut -d' ' -f1
208+
```shell
209+
echo -n "password" | npx argon2-cli -e
210+
$argon2i$v=19$m=4096,t=3,p=1$wst5qhbgk2lu1ih4dmuxvg$ls1alrvdiwtvzhwnzcm1dugg+5dto3dt1d5v9xtlws4
210211
```
211212

212-
Of course replace `thisismypassword` with your actual password.
213+
Of course replace `thisismypassword` with your actual password and **remember to put it inside quotes**!
213214

214215
Example:
215216

216217
```yaml
217218
auth: password
218-
hashed-password: 1da9133ab9dbd11d2937ec8d312e1e2569857059e73cc72df92e670928983ab5 # You got this from the command above
219+
hashed-password: "$argon2i$v=19$m=4096,t=3,p=1$wST5QhBgk2lu1ih4DMuxvg$LS1alrVdIWtvZHwnzCM1DUGg+5DTO3Dt1d5v9XtLws4"
219220
```
220221
221222
## How do I securely access web services?

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
},
8989
"dependencies": {
9090
"@coder/logger": "1.1.16",
91+
"argon2": "^0.28.0",
9192
"body-parser": "^1.19.0",
9293
"compression": "^1.7.4",
9394
"cookie-parser": "^1.4.5",

‎src/node/cli.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ const options: Options<Required<Args>> = {
114114
"hashed-password": {
115115
type: "string",
116116
description:
117-
"The password hashed with SHA-256 for password authentication (can only be passed in via $HASHED_PASSWORD or the config file). \n" +
117+
"The password hashed with argon2 for password authentication (can only be passed in via $HASHED_PASSWORD or the config file). \n" +
118118
"Takes precedence over 'password'.",
119119
},
120120
cert: {
@@ -240,6 +240,19 @@ export const optionDescriptions = (): string[] => {
240240
})
241241
}
242242

243+
export function splitOnFirstEquals(str: string): string[] {
244+
// we use regex instead of "=" to ensure we split at the first
245+
// "=" and return the following substring with it
246+
// important for the hashed-password which looks like this
247+
// $argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY
248+
// 2 means return two items
249+
// Source: https://stackoverflow.com/a/4607799/3015595
250+
// We use the ? to say the the substr after the = is optional
251+
const split = str.split(/=(.+)?/, 2)
252+
253+
return split
254+
}
255+
243256
export const parse = (
244257
argv: string[],
245258
opts?: {
@@ -250,6 +263,7 @@ export const parse = (
250263
if (opts?.configFile) {
251264
msg = `error reading ${opts.configFile}: ${msg}`
252265
}
266+
253267
return new Error(msg)
254268
}
255269

@@ -270,7 +284,7 @@ export const parse = (
270284
let key: keyof Args | undefined
271285
let value: string | undefined
272286
if (arg.startsWith("--")) {
273-
const split = arg.replace(/^--/, "").split("=", 2)
287+
const split = splitOnFirstEquals(arg.replace(/^--/, ""))
274288
key = split[0] as keyof Args
275289
value = split[1]
276290
} else {

‎src/node/http.ts

+25-14
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ import { field, logger } from "@coder/logger"
22
import * as express from "express"
33
import * as expressCore from "express-serve-static-core"
44
import qs from "qs"
5-
import safeCompare from "safe-compare"
65
import { HttpCode, HttpError } from "../common/http"
76
import { normalize, Options } from "../common/util"
87
import { AuthType, DefaultedArgs } from "./cli"
98
import { commit, rootPath } from "./constants"
109
import { Heart } from "./heart"
11-
import { hash } from "./util"
10+
import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString } from "./util"
1211

1312
declare global {
1413
// eslint-disable-next-line @typescript-eslint/no-namespace
@@ -45,8 +44,13 @@ export const replaceTemplates = <T extends object>(
4544
/**
4645
* Throw an error if not authorized. Call `next` if provided.
4746
*/
48-
export const ensureAuthenticated = (req: express.Request, _?: express.Response, next?: express.NextFunction): void => {
49-
if (!authenticated(req)) {
47+
export const ensureAuthenticated = async (
48+
req: express.Request,
49+
_?: express.Response,
50+
next?: express.NextFunction,
51+
): Promise<void> => {
52+
const isAuthenticated = await authenticated(req)
53+
if (!isAuthenticated) {
5054
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
5155
}
5256
if (next) {
@@ -57,20 +61,27 @@ export const ensureAuthenticated = (req: express.Request, _?: express.Response,
5761
/**
5862
* Return true if authenticated via cookies.
5963
*/
60-
export const authenticated = (req: express.Request): boolean => {
64+
export const authenticated = async (req: express.Request): Promise<boolean> => {
6165
switch (req.args.auth) {
62-
case AuthType.None:
66+
case AuthType.None: {
6367
return true
64-
case AuthType.Password:
68+
}
69+
case AuthType.Password: {
6570
// The password is stored in the cookie after being hashed.
66-
return !!(
67-
req.cookies.key &&
68-
(req.args["hashed-password"]
69-
? safeCompare(req.cookies.key, req.args["hashed-password"])
70-
: req.args.password && safeCompare(req.cookies.key, hash(req.args.password)))
71-
)
72-
default:
71+
const hashedPasswordFromArgs = req.args["hashed-password"]
72+
const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
73+
const isCookieValidArgs: IsCookieValidArgs = {
74+
passwordMethod,
75+
cookieKey: sanitizeString(req.cookies.key),
76+
passwordFromArgs: req.args.password || "",
77+
hashedPasswordFromArgs: req.args["hashed-password"],
78+
}
79+
80+
return await isCookieValid(isCookieValidArgs)
81+
}
82+
default: {
7383
throw new Error(`Unsupported auth type ${req.args.auth}`)
84+
}
7485
}
7586
}
7687

‎src/node/routes/domainProxy.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ const maybeProxy = (req: Request): string | undefined => {
3232
return port
3333
}
3434

35-
router.all("*", (req, res, next) => {
35+
router.all("*", async (req, res, next) => {
3636
const port = maybeProxy(req)
3737
if (!port) {
3838
return next()
3939
}
4040

4141
// Must be authenticated to use the proxy.
42-
if (!authenticated(req)) {
42+
const isAuthenticated = await authenticated(req)
43+
if (!isAuthenticated) {
4344
// Let the assets through since they're used on the login page.
4445
if (req.path.startsWith("/static/") && req.method === "GET") {
4546
return next()
@@ -73,14 +74,14 @@ router.all("*", (req, res, next) => {
7374

7475
export const wsRouter = WsRouter()
7576

76-
wsRouter.ws("*", (req, _, next) => {
77+
wsRouter.ws("*", async (req, _, next) => {
7778
const port = maybeProxy(req)
7879
if (!port) {
7980
return next()
8081
}
8182

8283
// Must be authenticated to use the proxy.
83-
ensureAuthenticated(req)
84+
await ensureAuthenticated(req)
8485

8586
proxy.ws(req, req.ws, req.head, {
8687
ignorePath: true,

‎src/node/routes/index.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ export const register = async (
9898
app.all("/proxy/(:port)(/*)?", (req, res) => {
9999
pathProxy.proxy(req, res)
100100
})
101-
wsApp.get("/proxy/(:port)(/*)?", (req) => {
102-
pathProxy.wsProxy(req as pluginapi.WebsocketRequest)
101+
wsApp.get("/proxy/(:port)(/*)?", async (req) => {
102+
await pathProxy.wsProxy(req as pluginapi.WebsocketRequest)
103103
})
104104
// These two routes pass through the path directly.
105105
// So the proxied app must be aware it is running
@@ -109,8 +109,8 @@ export const register = async (
109109
passthroughPath: true,
110110
})
111111
})
112-
wsApp.get("/absproxy/(:port)(/*)?", (req) => {
113-
pathProxy.wsProxy(req as pluginapi.WebsocketRequest, {
112+
wsApp.get("/absproxy/(:port)(/*)?", async (req) => {
113+
await pathProxy.wsProxy(req as pluginapi.WebsocketRequest, {
114114
passthroughPath: true,
115115
})
116116
})

‎src/node/routes/login.ts

+17-11
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import { Router, Request } from "express"
22
import { promises as fs } from "fs"
33
import { RateLimiter as Limiter } from "limiter"
44
import * as path from "path"
5-
import safeCompare from "safe-compare"
65
import { rootPath } from "../constants"
76
import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http"
8-
import { hash, humanPath } from "../util"
7+
import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString } from "../util"
98

109
export enum Cookie {
1110
Key = "key",
@@ -49,9 +48,9 @@ const limiter = new RateLimiter()
4948

5049
export const router = Router()
5150

52-
router.use((req, res, next) => {
51+
router.use(async (req, res, next) => {
5352
const to = (typeof req.query.to === "string" && req.query.to) || "/"
54-
if (authenticated(req)) {
53+
if (await authenticated(req)) {
5554
return redirect(req, res, to, { to: undefined })
5655
}
5756
next()
@@ -62,24 +61,31 @@ router.get("/", async (req, res) => {
6261
})
6362

6463
router.post("/", async (req, res) => {
64+
const password = sanitizeString(req.body.password)
65+
const hashedPasswordFromArgs = req.args["hashed-password"]
66+
6567
try {
6668
// Check to see if they exceeded their login attempts
6769
if (!limiter.canTry()) {
6870
throw new Error("Login rate limited!")
6971
}
7072

71-
if (!req.body.password) {
73+
if (!password) {
7274
throw new Error("Missing password")
7375
}
7476

75-
if (
76-
req.args["hashed-password"]
77-
? safeCompare(hash(req.body.password), req.args["hashed-password"])
78-
: req.args.password && safeCompare(req.body.password, req.args.password)
79-
) {
77+
const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
78+
const { isPasswordValid, hashedPassword } = await handlePasswordValidation({
79+
passwordMethod,
80+
hashedPasswordFromArgs,
81+
passwordFromRequestBody: password,
82+
passwordFromArgs: req.args.password,
83+
})
84+
85+
if (isPasswordValid) {
8086
// The hash does not add any actual security but we do it for
8187
// obfuscation purposes (and as a side effect it handles escaping).
82-
res.cookie(Cookie.Key, hash(req.body.password), {
88+
res.cookie(Cookie.Key, hashedPassword, {
8389
domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]),
8490
path: req.body.base || "/",
8591
sameSite: "lax",

‎src/node/routes/pathProxy.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,13 @@ export function proxy(
4545
})
4646
}
4747

48-
export function wsProxy(
48+
export async function wsProxy(
4949
req: pluginapi.WebsocketRequest,
5050
opts?: {
5151
passthroughPath?: boolean
5252
},
53-
): void {
54-
ensureAuthenticated(req)
53+
): Promise<void> {
54+
await ensureAuthenticated(req)
5555
_proxy.ws(req, req.ws, req.head, {
5656
ignorePath: true,
5757
target: getProxyTarget(req, opts?.passthroughPath),

‎src/node/routes/static.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ router.get("/(:commit)(/*)?", async (req, res) => {
1818
// Used by VS Code to load extensions into the web worker.
1919
const tar = getFirstString(req.query.tar)
2020
if (tar) {
21-
ensureAuthenticated(req)
21+
await ensureAuthenticated(req)
2222
let stream: Readable = tarFs.pack(pathToFsPath(tar))
2323
if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) {
2424
logger.debug("gzipping tar", field("path", tar))
@@ -43,7 +43,8 @@ router.get("/(:commit)(/*)?", async (req, res) => {
4343

4444
// Make sure it's in code-server if you aren't authenticated. This lets
4545
// unauthenticated users load the login assets.
46-
if (!resourcePath.startsWith(rootPath) && !authenticated(req)) {
46+
const isAuthenticated = await authenticated(req)
47+
if (!resourcePath.startsWith(rootPath) && !isAuthenticated) {
4748
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
4849
}
4950

‎src/node/routes/vscode.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export const router = Router()
1919
const vscode = new VscodeProvider()
2020

2121
router.get("/", async (req, res) => {
22-
if (!authenticated(req)) {
22+
const isAuthenticated = await authenticated(req)
23+
if (!isAuthenticated) {
2324
return redirect(req, res, "login", {
2425
// req.baseUrl can be blank if already at the root.
2526
to: req.baseUrl && req.baseUrl !== "/" ? req.baseUrl : undefined,

‎src/node/util.ts

+175-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { logger } from "@coder/logger"
2+
import * as argon2 from "argon2"
13
import * as cp from "child_process"
24
import * as crypto from "crypto"
35
import envPaths from "env-paths"
46
import { promises as fs } from "fs"
57
import * as net from "net"
68
import * as os from "os"
79
import * as path from "path"
10+
import safeCompare from "safe-compare"
811
import * as util from "util"
912
import xdgBasedir from "xdg-basedir"
1013

@@ -115,10 +118,181 @@ export const generatePassword = async (length = 24): Promise<string> => {
115118
return buffer.toString("hex").substring(0, length)
116119
}
117120

118-
export const hash = (str: string): string => {
121+
/**
122+
* Used to hash the password.
123+
*/
124+
export const hash = async (password: string): Promise<string> => {
125+
try {
126+
return await argon2.hash(password)
127+
} catch (error) {
128+
logger.error(error)
129+
return ""
130+
}
131+
}
132+
133+
/**
134+
* Used to verify if the password matches the hash
135+
*/
136+
export const isHashMatch = async (password: string, hash: string) => {
137+
if (password === "" || hash === "") {
138+
return false
139+
}
140+
try {
141+
return await argon2.verify(hash, password)
142+
} catch (error) {
143+
logger.error(error)
144+
return false
145+
}
146+
}
147+
148+
/**
149+
* Used to hash the password using the sha256
150+
* algorithm. We only use this to for checking
151+
* the hashed-password set in the config.
152+
*
153+
* Kept for legacy reasons.
154+
*/
155+
export const hashLegacy = (str: string): string => {
119156
return crypto.createHash("sha256").update(str).digest("hex")
120157
}
121158

159+
/**
160+
* Used to check if the password matches the hash using
161+
* the hashLegacy function
162+
*/
163+
export const isHashLegacyMatch = (password: string, hashPassword: string) => {
164+
const hashedWithLegacy = hashLegacy(password)
165+
return safeCompare(hashedWithLegacy, hashPassword)
166+
}
167+
168+
const passwordMethods = ["SHA256", "ARGON2", "PLAIN_TEXT"] as const
169+
export type PasswordMethod = typeof passwordMethods[number]
170+
171+
/**
172+
* Used to determine the password method.
173+
*
174+
* There are three options for the return value:
175+
* 1. "SHA256" -> the legacy hashing algorithm
176+
* 2. "ARGON2" -> the newest hashing algorithm
177+
* 3. "PLAIN_TEXT" -> regular ol' password with no hashing
178+
*
179+
* @returns {PasswordMethod} "SHA256" | "ARGON2" | "PLAIN_TEXT"
180+
*/
181+
export function getPasswordMethod(hashedPassword: string | undefined): PasswordMethod {
182+
if (!hashedPassword) {
183+
return "PLAIN_TEXT"
184+
}
185+
186+
// This is the new hashing algorithm
187+
if (hashedPassword.includes("$argon")) {
188+
return "ARGON2"
189+
}
190+
191+
// This is the legacy hashing algorithm
192+
return "SHA256"
193+
}
194+
195+
type PasswordValidation = {
196+
isPasswordValid: boolean
197+
hashedPassword: string
198+
}
199+
200+
type HandlePasswordValidationArgs = {
201+
/** The PasswordMethod */
202+
passwordMethod: PasswordMethod
203+
/** The password provided by the user */
204+
passwordFromRequestBody: string
205+
/** The password set in PASSWORD or config */
206+
passwordFromArgs: string | undefined
207+
/** The hashed-password set in HASHED_PASSWORD or config */
208+
hashedPasswordFromArgs: string | undefined
209+
}
210+
211+
/**
212+
* Checks if a password is valid and also returns the hash
213+
* using the PasswordMethod
214+
*/
215+
export async function handlePasswordValidation({
216+
passwordMethod,
217+
passwordFromArgs,
218+
passwordFromRequestBody,
219+
hashedPasswordFromArgs,
220+
}: HandlePasswordValidationArgs): Promise<PasswordValidation> {
221+
const passwordValidation = <PasswordValidation>{
222+
isPasswordValid: false,
223+
hashedPassword: "",
224+
}
225+
226+
switch (passwordMethod) {
227+
case "PLAIN_TEXT": {
228+
const isValid = passwordFromArgs ? safeCompare(passwordFromRequestBody, passwordFromArgs) : false
229+
passwordValidation.isPasswordValid = isValid
230+
231+
const hashedPassword = await hash(passwordFromRequestBody)
232+
passwordValidation.hashedPassword = hashedPassword
233+
break
234+
}
235+
case "SHA256": {
236+
const isValid = isHashLegacyMatch(passwordFromRequestBody, hashedPasswordFromArgs || "")
237+
passwordValidation.isPasswordValid = isValid
238+
239+
passwordValidation.hashedPassword = hashedPasswordFromArgs || (await hashLegacy(passwordFromRequestBody))
240+
break
241+
}
242+
case "ARGON2": {
243+
const isValid = await isHashMatch(passwordFromRequestBody, hashedPasswordFromArgs || "")
244+
passwordValidation.isPasswordValid = isValid
245+
246+
passwordValidation.hashedPassword = hashedPasswordFromArgs || ""
247+
break
248+
}
249+
default:
250+
break
251+
}
252+
253+
return passwordValidation
254+
}
255+
256+
export type IsCookieValidArgs = {
257+
passwordMethod: PasswordMethod
258+
cookieKey: string
259+
hashedPasswordFromArgs: string | undefined
260+
passwordFromArgs: string | undefined
261+
}
262+
263+
/** Checks if a req.cookies.key is valid using the PasswordMethod */
264+
export async function isCookieValid({
265+
passwordFromArgs = "",
266+
cookieKey,
267+
hashedPasswordFromArgs = "",
268+
passwordMethod,
269+
}: IsCookieValidArgs): Promise<boolean> {
270+
let isValid = false
271+
switch (passwordMethod) {
272+
case "PLAIN_TEXT":
273+
isValid = await isHashMatch(passwordFromArgs, cookieKey)
274+
break
275+
case "ARGON2":
276+
case "SHA256":
277+
isValid = safeCompare(cookieKey, hashedPasswordFromArgs)
278+
break
279+
default:
280+
break
281+
}
282+
return isValid
283+
}
284+
285+
/** Ensures that the input is sanitized by checking
286+
* - it's a string
287+
* - greater than 0 characters
288+
* - trims whitespace
289+
*/
290+
export function sanitizeString(str: string): string {
291+
// Very basic sanitization of string
292+
// Credit: https://stackoverflow.com/a/46719000/3015595
293+
return typeof str === "string" && str.trim().length > 0 ? str.trim() : ""
294+
}
295+
122296
const mimeTypes: { [key: string]: string } = {
123297
".aac": "audio/x-aac",
124298
".avi": "video/x-msvideo",

‎test/config.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,21 @@ import {
88
Config,
99
globalSetup,
1010
} from "@playwright/test"
11-
import * as crypto from "crypto"
11+
import * as argon2 from "argon2"
1212
import path from "path"
1313
import { PASSWORD } from "./utils/constants"
1414
import * as wtfnode from "./utils/wtfnode"
1515

1616
// Playwright doesn't like that ../src/node/util has an enum in it
1717
// so I had to copy hash in separately
18-
const hash = (str: string): string => {
19-
return crypto.createHash("sha256").update(str).digest("hex")
18+
const hash = async (str: string): Promise<string> => {
19+
return await argon2.hash(str)
2020
}
2121

2222
const cookieToStore = {
2323
sameSite: "Lax" as const,
2424
name: "key",
25-
value: hash(PASSWORD),
25+
value: "",
2626
domain: "localhost",
2727
path: "/",
2828
expires: -1,
@@ -38,6 +38,8 @@ globalSetup(async () => {
3838
wtfnode.setup()
3939
}
4040

41+
cookieToStore.value = await hash(PASSWORD)
42+
4143
const storage = {
4244
cookies: [cookieToStore],
4345
}

‎test/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"@types/jsdom": "^16.2.6",
88
"@types/node-fetch": "^2.5.8",
99
"@types/supertest": "^2.0.10",
10+
"argon2": "^0.28.0",
1011
"jest": "^26.6.3",
1112
"jsdom": "^16.4.0",
1213
"node-fetch": "^2.6.1",

‎test/unit/cli.test.ts

+57-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { promises as fs } from "fs"
33
import * as net from "net"
44
import * as os from "os"
55
import * as path from "path"
6-
import { Args, parse, setDefaults, shouldOpenInExistingInstance } from "../../src/node/cli"
6+
import { Args, parse, setDefaults, shouldOpenInExistingInstance, splitOnFirstEquals } from "../../src/node/cli"
77
import { tmpdir } from "../../src/node/constants"
88
import { paths } from "../../src/node/util"
99

@@ -306,7 +306,8 @@ describe("parser", () => {
306306
})
307307

308308
it("should use env var hashed password", async () => {
309-
process.env.HASHED_PASSWORD = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" // test
309+
process.env.HASHED_PASSWORD =
310+
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY" // test
310311
const args = parse([])
311312
expect(args).toEqual({
312313
_: [],
@@ -316,7 +317,8 @@ describe("parser", () => {
316317
expect(defaultArgs).toEqual({
317318
...defaults,
318319
_: [],
319-
"hashed-password": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
320+
"hashed-password":
321+
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
320322
usingEnvHashedPassword: true,
321323
})
322324
})
@@ -335,6 +337,33 @@ describe("parser", () => {
335337
"proxy-domain": ["coder.com", "coder.org"],
336338
})
337339
})
340+
it("should allow '=,$/' in strings", async () => {
341+
const args = parse([
342+
"--enable-proposed-api",
343+
"$argon2i$v=19$m=4096,t=3,p=1$0qr/o+0t00hsbjfqcksfdq$ofcm4rl6o+b7oxpua4qlxubypbbpsf+8l531u7p9hyy",
344+
])
345+
expect(args).toEqual({
346+
_: [],
347+
"enable-proposed-api": [
348+
"$argon2i$v=19$m=4096,t=3,p=1$0qr/o+0t00hsbjfqcksfdq$ofcm4rl6o+b7oxpua4qlxubypbbpsf+8l531u7p9hyy",
349+
],
350+
})
351+
})
352+
it("should parse options with double-dash and multiple equal signs ", async () => {
353+
const args = parse(
354+
[
355+
"--hashed-password=$argon2i$v=19$m=4096,t=3,p=1$0qr/o+0t00hsbjfqcksfdq$ofcm4rl6o+b7oxpua4qlxubypbbpsf+8l531u7p9hyy",
356+
],
357+
{
358+
configFile: "/pathtoconfig",
359+
},
360+
)
361+
expect(args).toEqual({
362+
_: [],
363+
"hashed-password":
364+
"$argon2i$v=19$m=4096,t=3,p=1$0qr/o+0t00hsbjfqcksfdq$ofcm4rl6o+b7oxpua4qlxubypbbpsf+8l531u7p9hyy",
365+
})
366+
})
338367
})
339368

340369
describe("cli", () => {
@@ -409,3 +438,28 @@ describe("cli", () => {
409438
expect(await shouldOpenInExistingInstance(args)).toStrictEqual(undefined)
410439
})
411440
})
441+
442+
describe("splitOnFirstEquals", () => {
443+
it("should split on the first equals", () => {
444+
const testStr = "enabled-proposed-api=test=value"
445+
const actual = splitOnFirstEquals(testStr)
446+
const expected = ["enabled-proposed-api", "test=value"]
447+
expect(actual).toEqual(expect.arrayContaining(expected))
448+
})
449+
it("should split on first equals regardless of multiple equals signs", () => {
450+
const testStr =
451+
"hashed-password=$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY"
452+
const actual = splitOnFirstEquals(testStr)
453+
const expected = [
454+
"hashed-password",
455+
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
456+
]
457+
expect(actual).toEqual(expect.arrayContaining(expected))
458+
})
459+
it("should always return the first element before an equals", () => {
460+
const testStr = "auth="
461+
const actual = splitOnFirstEquals(testStr)
462+
const expected = ["auth"]
463+
expect(actual).toEqual(expect.arrayContaining(expected))
464+
})
465+
})

‎test/unit/node/util.test.ts

+262
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
import {
2+
hash,
3+
isHashMatch,
4+
handlePasswordValidation,
5+
PasswordMethod,
6+
getPasswordMethod,
7+
hashLegacy,
8+
isHashLegacyMatch,
9+
isCookieValid,
10+
sanitizeString,
11+
} from "../../../src/node/util"
12+
113
describe("getEnvPaths", () => {
214
describe("on darwin", () => {
315
let ORIGINAL_PLATFORM = ""
@@ -145,3 +157,253 @@ describe("getEnvPaths", () => {
145157
})
146158
})
147159
})
160+
161+
describe("hash", () => {
162+
it("should return a hash of the string passed in", async () => {
163+
const plainTextPassword = "mySecretPassword123"
164+
const hashed = await hash(plainTextPassword)
165+
expect(hashed).not.toBe(plainTextPassword)
166+
})
167+
})
168+
169+
describe("isHashMatch", () => {
170+
it("should return true if the password matches the hash", async () => {
171+
const password = "codeserver1234"
172+
const _hash = await hash(password)
173+
const actual = await isHashMatch(password, _hash)
174+
expect(actual).toBe(true)
175+
})
176+
it("should return false if the password does not match the hash", async () => {
177+
const password = "password123"
178+
const _hash = await hash(password)
179+
const actual = await isHashMatch("otherPassword123", _hash)
180+
expect(actual).toBe(false)
181+
})
182+
it("should return true with actual hash", async () => {
183+
const password = "password123"
184+
const _hash = "$argon2i$v=19$m=4096,t=3,p=1$EAoczTxVki21JDfIZpTUxg$rkXgyrW4RDGoDYrxBFD4H2DlSMEhP4h+Api1hXnGnFY"
185+
const actual = await isHashMatch(password, _hash)
186+
expect(actual).toBe(true)
187+
})
188+
it("should return false if the password is empty", async () => {
189+
const password = ""
190+
const _hash = "$argon2i$v=19$m=4096,t=3,p=1$EAoczTxVki21JDfIZpTUxg$rkXgyrW4RDGoDYrxBFD4H2DlSMEhP4h+Api1hXnGnFY"
191+
const actual = await isHashMatch(password, _hash)
192+
expect(actual).toBe(false)
193+
})
194+
it("should return false if the hash is empty", async () => {
195+
const password = "hellowpasssword"
196+
const _hash = ""
197+
const actual = await isHashMatch(password, _hash)
198+
expect(actual).toBe(false)
199+
})
200+
})
201+
202+
describe("hashLegacy", () => {
203+
it("should return a hash of the string passed in", () => {
204+
const plainTextPassword = "mySecretPassword123"
205+
const hashed = hashLegacy(plainTextPassword)
206+
expect(hashed).not.toBe(plainTextPassword)
207+
})
208+
})
209+
210+
describe("isHashLegacyMatch", () => {
211+
it("should return true if is match", () => {
212+
const password = "password123"
213+
const _hash = hashLegacy(password)
214+
expect(isHashLegacyMatch(password, _hash)).toBe(true)
215+
})
216+
it("should return false if is match", () => {
217+
const password = "password123"
218+
const _hash = hashLegacy(password)
219+
expect(isHashLegacyMatch("otherPassword123", _hash)).toBe(false)
220+
})
221+
it("should return true if hashed from command line", () => {
222+
const password = "password123"
223+
// Hashed using printf "password123" | sha256sum | cut -d' ' -f1
224+
const _hash = "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
225+
expect(isHashLegacyMatch(password, _hash)).toBe(true)
226+
})
227+
})
228+
229+
describe("getPasswordMethod", () => {
230+
it("should return PLAIN_TEXT for no hashed password", () => {
231+
const hashedPassword = undefined
232+
const passwordMethod = getPasswordMethod(hashedPassword)
233+
const expected: PasswordMethod = "PLAIN_TEXT"
234+
expect(passwordMethod).toEqual(expected)
235+
})
236+
it("should return ARGON2 for password with 'argon2'", () => {
237+
const hashedPassword =
238+
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY"
239+
const passwordMethod = getPasswordMethod(hashedPassword)
240+
const expected: PasswordMethod = "ARGON2"
241+
expect(passwordMethod).toEqual(expected)
242+
})
243+
it("should return SHA256 for password with legacy hash", () => {
244+
const hashedPassword = "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af"
245+
const passwordMethod = getPasswordMethod(hashedPassword)
246+
const expected: PasswordMethod = "SHA256"
247+
expect(passwordMethod).toEqual(expected)
248+
})
249+
})
250+
251+
describe("handlePasswordValidation", () => {
252+
it("should return true with a hashedPassword for a PLAIN_TEXT password", async () => {
253+
const p = "password"
254+
const passwordValidation = await handlePasswordValidation({
255+
passwordMethod: "PLAIN_TEXT",
256+
passwordFromRequestBody: p,
257+
passwordFromArgs: p,
258+
hashedPasswordFromArgs: undefined,
259+
})
260+
261+
const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword)
262+
263+
expect(passwordValidation.isPasswordValid).toBe(true)
264+
expect(matchesHash).toBe(true)
265+
})
266+
it("should return false when PLAIN_TEXT password doesn't match args", async () => {
267+
const p = "password"
268+
const passwordValidation = await handlePasswordValidation({
269+
passwordMethod: "PLAIN_TEXT",
270+
passwordFromRequestBody: "password1",
271+
passwordFromArgs: p,
272+
hashedPasswordFromArgs: undefined,
273+
})
274+
275+
const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword)
276+
277+
expect(passwordValidation.isPasswordValid).toBe(false)
278+
expect(matchesHash).toBe(false)
279+
})
280+
it("should return true with a hashedPassword for a SHA256 password", async () => {
281+
const p = "helloworld"
282+
const passwordValidation = await handlePasswordValidation({
283+
passwordMethod: "SHA256",
284+
passwordFromRequestBody: p,
285+
passwordFromArgs: undefined,
286+
hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af",
287+
})
288+
289+
const matchesHash = isHashLegacyMatch(p, passwordValidation.hashedPassword)
290+
291+
expect(passwordValidation.isPasswordValid).toBe(true)
292+
expect(matchesHash).toBe(true)
293+
})
294+
it("should return false when SHA256 password doesn't match hash", async () => {
295+
const p = "helloworld1"
296+
const passwordValidation = await handlePasswordValidation({
297+
passwordMethod: "SHA256",
298+
passwordFromRequestBody: p,
299+
passwordFromArgs: undefined,
300+
hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af",
301+
})
302+
303+
const matchesHash = isHashLegacyMatch(p, passwordValidation.hashedPassword)
304+
305+
expect(passwordValidation.isPasswordValid).toBe(false)
306+
expect(matchesHash).toBe(false)
307+
})
308+
it("should return true with a hashedPassword for a ARGON2 password", async () => {
309+
const p = "password"
310+
const passwordValidation = await handlePasswordValidation({
311+
passwordMethod: "ARGON2",
312+
passwordFromRequestBody: p,
313+
passwordFromArgs: undefined,
314+
hashedPasswordFromArgs:
315+
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
316+
})
317+
318+
const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword)
319+
320+
expect(passwordValidation.isPasswordValid).toBe(true)
321+
expect(matchesHash).toBe(true)
322+
})
323+
it("should return false when ARGON2 password doesn't match hash", async () => {
324+
const p = "password1"
325+
const passwordValidation = await handlePasswordValidation({
326+
passwordMethod: "ARGON2",
327+
passwordFromRequestBody: p,
328+
passwordFromArgs: undefined,
329+
hashedPasswordFromArgs:
330+
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
331+
})
332+
333+
const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword)
334+
335+
expect(passwordValidation.isPasswordValid).toBe(false)
336+
expect(matchesHash).toBe(false)
337+
})
338+
})
339+
340+
describe("isCookieValid", () => {
341+
it("should be valid if hashed-password for SHA256 matches cookie.key", async () => {
342+
const isValid = await isCookieValid({
343+
passwordMethod: "SHA256",
344+
cookieKey: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af",
345+
hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af",
346+
passwordFromArgs: undefined,
347+
})
348+
expect(isValid).toBe(true)
349+
})
350+
it("should be invalid if hashed-password for SHA256 does not match cookie.key", async () => {
351+
const isValid = await isCookieValid({
352+
passwordMethod: "SHA256",
353+
cookieKey: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb9442bb6f8f8f07af",
354+
hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af",
355+
passwordFromArgs: undefined,
356+
})
357+
expect(isValid).toBe(false)
358+
})
359+
it("should be valid if hashed-password for ARGON2 matches cookie.key", async () => {
360+
const isValid = await isCookieValid({
361+
passwordMethod: "ARGON2",
362+
cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
363+
hashedPasswordFromArgs:
364+
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
365+
passwordFromArgs: undefined,
366+
})
367+
expect(isValid).toBe(true)
368+
})
369+
it("should be invalid if hashed-password for ARGON2 does not match cookie.key", async () => {
370+
const isValid = await isCookieValid({
371+
passwordMethod: "ARGON2",
372+
cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9H",
373+
hashedPasswordFromArgs:
374+
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
375+
passwordFromArgs: undefined,
376+
})
377+
expect(isValid).toBe(false)
378+
})
379+
it("should be valid if password for PLAIN_TEXT matches cookie.key", async () => {
380+
const isValid = await isCookieValid({
381+
passwordMethod: "PLAIN_TEXT",
382+
cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
383+
passwordFromArgs: "password",
384+
hashedPasswordFromArgs: undefined,
385+
})
386+
expect(isValid).toBe(true)
387+
})
388+
it("should be invalid if hashed-password for PLAIN_TEXT does not match cookie.key", async () => {
389+
const isValid = await isCookieValid({
390+
passwordMethod: "PLAIN_TEXT",
391+
cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9H",
392+
passwordFromArgs: "password1234",
393+
hashedPasswordFromArgs: undefined,
394+
})
395+
expect(isValid).toBe(false)
396+
})
397+
})
398+
399+
describe("sanitizeString", () => {
400+
it("should return an empty string if passed a type other than a string", () => {
401+
expect(sanitizeString({} as string)).toBe("")
402+
})
403+
it("should trim whitespace", () => {
404+
expect(sanitizeString(" hello ")).toBe("hello")
405+
})
406+
it("should always return an empty string", () => {
407+
expect(sanitizeString(" ")).toBe("")
408+
})
409+
})

‎test/yarn.lock

+263-8
Large diffs are not rendered by default.

‎typings/pluginapi.d.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,16 @@ export const proxy: ProxyServer
145145
/**
146146
* Middleware to ensure the user is authenticated. Throws if they are not.
147147
*/
148-
export function ensureAuthenticated(req: express.Request, res?: express.Response, next?: express.NextFunction): void
148+
export function ensureAuthenticated(
149+
req: express.Request,
150+
res?: express.Response,
151+
next?: express.NextFunction,
152+
): Promise<void>
149153

150154
/**
151155
* Returns true if the user is authenticated.
152156
*/
153-
export function authenticated(req: express.Request): boolean
157+
export function authenticated(req: express.Request): Promise<void>
154158

155159
/**
156160
* Replace variables in HTML: TO, BASE, CS_STATIC_BASE, and OPTIONS.

‎yarn.lock

+224-6
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.