未验证 提交 717eaa64 编写于 作者: J Joe Previte 提交者: GitHub

Merge pull request #3422 from cdr/jsjoeio/fix-password-hash

fix: use sufficient computational effort for password hash
...@@ -59,6 +59,7 @@ VS Code v0.00.0 ...@@ -59,6 +59,7 @@ VS Code v0.00.0
- chore: cross-compile docker images with buildx #3166 @oxy - chore: cross-compile docker images with buildx #3166 @oxy
- chore: update node to v14 #3458 @oxy - chore: update node to v14 #3458 @oxy
- chore: update .gitignore #3557 @cuining - chore: update .gitignore #3557 @cuining
- fix: use sufficient computational effort for password hash #3422 @jsjoeio
### Development ### Development
......
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# This is due to an upstream issue with RHEL7/CentOS 7 comptability with node-argon2
# See: https://github.com/cdr/code-server/pull/3422#pullrequestreview-677765057
export npm_config_build_from_source=true
main() { main() {
cd "$(dirname "${0}")/../.." cd "$(dirname "${0}")/../.."
source ./ci/lib.sh source ./ci/lib.sh
......
...@@ -18,6 +18,9 @@ detect_arch() { ...@@ -18,6 +18,9 @@ detect_arch() {
} }
ARCH="${NPM_CONFIG_ARCH:-$(detect_arch)}" ARCH="${NPM_CONFIG_ARCH:-$(detect_arch)}"
# This is due to an upstream issue with RHEL7/CentOS 7 comptability with node-argon2
# See: https://github.com/cdr/code-server/pull/3422#pullrequestreview-677765057
export npm_config_build_from_source=true
main() { main() {
# Grabs the major version of node from $npm_config_user_agent which looks like # Grabs the major version of node from $npm_config_user_agent which looks like
......
...@@ -205,17 +205,18 @@ Again, please follow [./guide.md](./guide.md) for our recommendations on setting ...@@ -205,17 +205,18 @@ Again, please follow [./guide.md](./guide.md) for our recommendations on setting
Yes you can! Set the value of `hashed-password` instead of `password`. Generate the hash with: Yes you can! Set the value of `hashed-password` instead of `password`. Generate the hash with:
``` ```shell
printf "thisismypassword" | sha256sum | cut -d' ' -f1 echo -n "password" | npx argon2-cli -e
$argon2i$v=19$m=4096,t=3,p=1$wst5qhbgk2lu1ih4dmuxvg$ls1alrvdiwtvzhwnzcm1dugg+5dto3dt1d5v9xtlws4
``` ```
Of course replace `thisismypassword` with your actual password. Of course replace `thisismypassword` with your actual password and **remember to put it inside quotes**!
Example: Example:
```yaml ```yaml
auth: password auth: password
hashed-password: 1da9133ab9dbd11d2937ec8d312e1e2569857059e73cc72df92e670928983ab5 # You got this from the command above hashed-password: "$argon2i$v=19$m=4096,t=3,p=1$wST5QhBgk2lu1ih4DMuxvg$LS1alrVdIWtvZHwnzCM1DUGg+5DTO3Dt1d5v9XtLws4"
``` ```
## How do I securely access web services? ## How do I securely access web services?
......
...@@ -88,6 +88,7 @@ ...@@ -88,6 +88,7 @@
}, },
"dependencies": { "dependencies": {
"@coder/logger": "1.1.16", "@coder/logger": "1.1.16",
"argon2": "^0.28.0",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"cookie-parser": "^1.4.5", "cookie-parser": "^1.4.5",
......
...@@ -114,7 +114,7 @@ const options: Options<Required<Args>> = { ...@@ -114,7 +114,7 @@ const options: Options<Required<Args>> = {
"hashed-password": { "hashed-password": {
type: "string", type: "string",
description: description:
"The password hashed with SHA-256 for password authentication (can only be passed in via $HASHED_PASSWORD or the config file). \n" + "The password hashed with argon2 for password authentication (can only be passed in via $HASHED_PASSWORD or the config file). \n" +
"Takes precedence over 'password'.", "Takes precedence over 'password'.",
}, },
cert: { cert: {
...@@ -240,6 +240,19 @@ export const optionDescriptions = (): string[] => { ...@@ -240,6 +240,19 @@ export const optionDescriptions = (): string[] => {
}) })
} }
export function splitOnFirstEquals(str: string): string[] {
// we use regex instead of "=" to ensure we split at the first
// "=" and return the following substring with it
// important for the hashed-password which looks like this
// $argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY
// 2 means return two items
// Source: https://stackoverflow.com/a/4607799/3015595
// We use the ? to say the the substr after the = is optional
const split = str.split(/=(.+)?/, 2)
return split
}
export const parse = ( export const parse = (
argv: string[], argv: string[],
opts?: { opts?: {
...@@ -250,6 +263,7 @@ export const parse = ( ...@@ -250,6 +263,7 @@ export const parse = (
if (opts?.configFile) { if (opts?.configFile) {
msg = `error reading ${opts.configFile}: ${msg}` msg = `error reading ${opts.configFile}: ${msg}`
} }
return new Error(msg) return new Error(msg)
} }
...@@ -270,7 +284,7 @@ export const parse = ( ...@@ -270,7 +284,7 @@ export const parse = (
let key: keyof Args | undefined let key: keyof Args | undefined
let value: string | undefined let value: string | undefined
if (arg.startsWith("--")) { if (arg.startsWith("--")) {
const split = arg.replace(/^--/, "").split("=", 2) const split = splitOnFirstEquals(arg.replace(/^--/, ""))
key = split[0] as keyof Args key = split[0] as keyof Args
value = split[1] value = split[1]
} else { } else {
......
...@@ -2,13 +2,12 @@ import { field, logger } from "@coder/logger" ...@@ -2,13 +2,12 @@ import { field, logger } from "@coder/logger"
import * as express from "express" import * as express from "express"
import * as expressCore from "express-serve-static-core" import * as expressCore from "express-serve-static-core"
import qs from "qs" import qs from "qs"
import safeCompare from "safe-compare"
import { HttpCode, HttpError } from "../common/http" import { HttpCode, HttpError } from "../common/http"
import { normalize, Options } from "../common/util" import { normalize, Options } from "../common/util"
import { AuthType, DefaultedArgs } from "./cli" import { AuthType, DefaultedArgs } from "./cli"
import { commit, rootPath } from "./constants" import { commit, rootPath } from "./constants"
import { Heart } from "./heart" import { Heart } from "./heart"
import { hash } from "./util" import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString } from "./util"
declare global { declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace // eslint-disable-next-line @typescript-eslint/no-namespace
...@@ -45,8 +44,13 @@ export const replaceTemplates = <T extends object>( ...@@ -45,8 +44,13 @@ export const replaceTemplates = <T extends object>(
/** /**
* Throw an error if not authorized. Call `next` if provided. * Throw an error if not authorized. Call `next` if provided.
*/ */
export const ensureAuthenticated = (req: express.Request, _?: express.Response, next?: express.NextFunction): void => { export const ensureAuthenticated = async (
if (!authenticated(req)) { req: express.Request,
_?: express.Response,
next?: express.NextFunction,
): Promise<void> => {
const isAuthenticated = await authenticated(req)
if (!isAuthenticated) {
throw new HttpError("Unauthorized", HttpCode.Unauthorized) throw new HttpError("Unauthorized", HttpCode.Unauthorized)
} }
if (next) { if (next) {
...@@ -57,20 +61,27 @@ export const ensureAuthenticated = (req: express.Request, _?: express.Response, ...@@ -57,20 +61,27 @@ export const ensureAuthenticated = (req: express.Request, _?: express.Response,
/** /**
* Return true if authenticated via cookies. * Return true if authenticated via cookies.
*/ */
export const authenticated = (req: express.Request): boolean => { export const authenticated = async (req: express.Request): Promise<boolean> => {
switch (req.args.auth) { switch (req.args.auth) {
case AuthType.None: case AuthType.None: {
return true return true
case AuthType.Password: }
case AuthType.Password: {
// The password is stored in the cookie after being hashed. // The password is stored in the cookie after being hashed.
return !!( const hashedPasswordFromArgs = req.args["hashed-password"]
req.cookies.key && const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
(req.args["hashed-password"] const isCookieValidArgs: IsCookieValidArgs = {
? safeCompare(req.cookies.key, req.args["hashed-password"]) passwordMethod,
: req.args.password && safeCompare(req.cookies.key, hash(req.args.password))) cookieKey: sanitizeString(req.cookies.key),
) passwordFromArgs: req.args.password || "",
default: hashedPasswordFromArgs: req.args["hashed-password"],
}
return await isCookieValid(isCookieValidArgs)
}
default: {
throw new Error(`Unsupported auth type ${req.args.auth}`) throw new Error(`Unsupported auth type ${req.args.auth}`)
}
} }
} }
......
...@@ -32,14 +32,15 @@ const maybeProxy = (req: Request): string | undefined => { ...@@ -32,14 +32,15 @@ const maybeProxy = (req: Request): string | undefined => {
return port return port
} }
router.all("*", (req, res, next) => { router.all("*", async (req, res, next) => {
const port = maybeProxy(req) const port = maybeProxy(req)
if (!port) { if (!port) {
return next() return next()
} }
// Must be authenticated to use the proxy. // Must be authenticated to use the proxy.
if (!authenticated(req)) { const isAuthenticated = await authenticated(req)
if (!isAuthenticated) {
// Let the assets through since they're used on the login page. // Let the assets through since they're used on the login page.
if (req.path.startsWith("/static/") && req.method === "GET") { if (req.path.startsWith("/static/") && req.method === "GET") {
return next() return next()
...@@ -73,14 +74,14 @@ router.all("*", (req, res, next) => { ...@@ -73,14 +74,14 @@ router.all("*", (req, res, next) => {
export const wsRouter = WsRouter() export const wsRouter = WsRouter()
wsRouter.ws("*", (req, _, next) => { wsRouter.ws("*", async (req, _, next) => {
const port = maybeProxy(req) const port = maybeProxy(req)
if (!port) { if (!port) {
return next() return next()
} }
// Must be authenticated to use the proxy. // Must be authenticated to use the proxy.
ensureAuthenticated(req) await ensureAuthenticated(req)
proxy.ws(req, req.ws, req.head, { proxy.ws(req, req.ws, req.head, {
ignorePath: true, ignorePath: true,
......
...@@ -98,8 +98,8 @@ export const register = async ( ...@@ -98,8 +98,8 @@ export const register = async (
app.all("/proxy/(:port)(/*)?", (req, res) => { app.all("/proxy/(:port)(/*)?", (req, res) => {
pathProxy.proxy(req, res) pathProxy.proxy(req, res)
}) })
wsApp.get("/proxy/(:port)(/*)?", (req) => { wsApp.get("/proxy/(:port)(/*)?", async (req) => {
pathProxy.wsProxy(req as pluginapi.WebsocketRequest) await pathProxy.wsProxy(req as pluginapi.WebsocketRequest)
}) })
// These two routes pass through the path directly. // These two routes pass through the path directly.
// So the proxied app must be aware it is running // So the proxied app must be aware it is running
...@@ -109,8 +109,8 @@ export const register = async ( ...@@ -109,8 +109,8 @@ export const register = async (
passthroughPath: true, passthroughPath: true,
}) })
}) })
wsApp.get("/absproxy/(:port)(/*)?", (req) => { wsApp.get("/absproxy/(:port)(/*)?", async (req) => {
pathProxy.wsProxy(req as pluginapi.WebsocketRequest, { await pathProxy.wsProxy(req as pluginapi.WebsocketRequest, {
passthroughPath: true, passthroughPath: true,
}) })
}) })
......
...@@ -2,10 +2,9 @@ import { Router, Request } from "express" ...@@ -2,10 +2,9 @@ import { Router, Request } from "express"
import { promises as fs } from "fs" import { promises as fs } from "fs"
import { RateLimiter as Limiter } from "limiter" import { RateLimiter as Limiter } from "limiter"
import * as path from "path" import * as path from "path"
import safeCompare from "safe-compare"
import { rootPath } from "../constants" import { rootPath } from "../constants"
import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http" import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http"
import { hash, humanPath } from "../util" import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString } from "../util"
export enum Cookie { export enum Cookie {
Key = "key", Key = "key",
...@@ -49,9 +48,9 @@ const limiter = new RateLimiter() ...@@ -49,9 +48,9 @@ const limiter = new RateLimiter()
export const router = Router() export const router = Router()
router.use((req, res, next) => { router.use(async (req, res, next) => {
const to = (typeof req.query.to === "string" && req.query.to) || "/" const to = (typeof req.query.to === "string" && req.query.to) || "/"
if (authenticated(req)) { if (await authenticated(req)) {
return redirect(req, res, to, { to: undefined }) return redirect(req, res, to, { to: undefined })
} }
next() next()
...@@ -62,24 +61,31 @@ router.get("/", async (req, res) => { ...@@ -62,24 +61,31 @@ router.get("/", async (req, res) => {
}) })
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
const password = sanitizeString(req.body.password)
const hashedPasswordFromArgs = req.args["hashed-password"]
try { try {
// Check to see if they exceeded their login attempts // Check to see if they exceeded their login attempts
if (!limiter.canTry()) { if (!limiter.canTry()) {
throw new Error("Login rate limited!") throw new Error("Login rate limited!")
} }
if (!req.body.password) { if (!password) {
throw new Error("Missing password") throw new Error("Missing password")
} }
if ( const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
req.args["hashed-password"] const { isPasswordValid, hashedPassword } = await handlePasswordValidation({
? safeCompare(hash(req.body.password), req.args["hashed-password"]) passwordMethod,
: req.args.password && safeCompare(req.body.password, req.args.password) hashedPasswordFromArgs,
) { passwordFromRequestBody: password,
passwordFromArgs: req.args.password,
})
if (isPasswordValid) {
// The hash does not add any actual security but we do it for // The hash does not add any actual security but we do it for
// obfuscation purposes (and as a side effect it handles escaping). // obfuscation purposes (and as a side effect it handles escaping).
res.cookie(Cookie.Key, hash(req.body.password), { res.cookie(Cookie.Key, hashedPassword, {
domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]), domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]),
path: req.body.base || "/", path: req.body.base || "/",
sameSite: "lax", sameSite: "lax",
......
...@@ -45,13 +45,13 @@ export function proxy( ...@@ -45,13 +45,13 @@ export function proxy(
}) })
} }
export function wsProxy( export async function wsProxy(
req: pluginapi.WebsocketRequest, req: pluginapi.WebsocketRequest,
opts?: { opts?: {
passthroughPath?: boolean passthroughPath?: boolean
}, },
): void { ): Promise<void> {
ensureAuthenticated(req) await ensureAuthenticated(req)
_proxy.ws(req, req.ws, req.head, { _proxy.ws(req, req.ws, req.head, {
ignorePath: true, ignorePath: true,
target: getProxyTarget(req, opts?.passthroughPath), target: getProxyTarget(req, opts?.passthroughPath),
......
...@@ -18,7 +18,7 @@ router.get("/(:commit)(/*)?", async (req, res) => { ...@@ -18,7 +18,7 @@ router.get("/(:commit)(/*)?", async (req, res) => {
// Used by VS Code to load extensions into the web worker. // Used by VS Code to load extensions into the web worker.
const tar = getFirstString(req.query.tar) const tar = getFirstString(req.query.tar)
if (tar) { if (tar) {
ensureAuthenticated(req) await ensureAuthenticated(req)
let stream: Readable = tarFs.pack(pathToFsPath(tar)) let stream: Readable = tarFs.pack(pathToFsPath(tar))
if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) { if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) {
logger.debug("gzipping tar", field("path", tar)) logger.debug("gzipping tar", field("path", tar))
...@@ -43,7 +43,8 @@ router.get("/(:commit)(/*)?", async (req, res) => { ...@@ -43,7 +43,8 @@ router.get("/(:commit)(/*)?", async (req, res) => {
// Make sure it's in code-server if you aren't authenticated. This lets // Make sure it's in code-server if you aren't authenticated. This lets
// unauthenticated users load the login assets. // unauthenticated users load the login assets.
if (!resourcePath.startsWith(rootPath) && !authenticated(req)) { const isAuthenticated = await authenticated(req)
if (!resourcePath.startsWith(rootPath) && !isAuthenticated) {
throw new HttpError("Unauthorized", HttpCode.Unauthorized) throw new HttpError("Unauthorized", HttpCode.Unauthorized)
} }
......
...@@ -19,7 +19,8 @@ export const router = Router() ...@@ -19,7 +19,8 @@ export const router = Router()
const vscode = new VscodeProvider() const vscode = new VscodeProvider()
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
if (!authenticated(req)) { const isAuthenticated = await authenticated(req)
if (!isAuthenticated) {
return redirect(req, res, "login", { return redirect(req, res, "login", {
// req.baseUrl can be blank if already at the root. // req.baseUrl can be blank if already at the root.
to: req.baseUrl && req.baseUrl !== "/" ? req.baseUrl : undefined, to: req.baseUrl && req.baseUrl !== "/" ? req.baseUrl : undefined,
......
import { logger } from "@coder/logger"
import * as argon2 from "argon2"
import * as cp from "child_process" import * as cp from "child_process"
import * as crypto from "crypto" import * as crypto from "crypto"
import envPaths from "env-paths" import envPaths from "env-paths"
...@@ -5,6 +7,7 @@ import { promises as fs } from "fs" ...@@ -5,6 +7,7 @@ import { promises as fs } from "fs"
import * as net from "net" import * as net from "net"
import * as os from "os" import * as os from "os"
import * as path from "path" import * as path from "path"
import safeCompare from "safe-compare"
import * as util from "util" import * as util from "util"
import xdgBasedir from "xdg-basedir" import xdgBasedir from "xdg-basedir"
...@@ -115,10 +118,181 @@ export const generatePassword = async (length = 24): Promise<string> => { ...@@ -115,10 +118,181 @@ export const generatePassword = async (length = 24): Promise<string> => {
return buffer.toString("hex").substring(0, length) return buffer.toString("hex").substring(0, length)
} }
export const hash = (str: string): string => { /**
* Used to hash the password.
*/
export const hash = async (password: string): Promise<string> => {
try {
return await argon2.hash(password)
} catch (error) {
logger.error(error)
return ""
}
}
/**
* Used to verify if the password matches the hash
*/
export const isHashMatch = async (password: string, hash: string) => {
if (password === "" || hash === "") {
return false
}
try {
return await argon2.verify(hash, password)
} catch (error) {
logger.error(error)
return false
}
}
/**
* Used to hash the password using the sha256
* algorithm. We only use this to for checking
* the hashed-password set in the config.
*
* Kept for legacy reasons.
*/
export const hashLegacy = (str: string): string => {
return crypto.createHash("sha256").update(str).digest("hex") return crypto.createHash("sha256").update(str).digest("hex")
} }
/**
* Used to check if the password matches the hash using
* the hashLegacy function
*/
export const isHashLegacyMatch = (password: string, hashPassword: string) => {
const hashedWithLegacy = hashLegacy(password)
return safeCompare(hashedWithLegacy, hashPassword)
}
const passwordMethods = ["SHA256", "ARGON2", "PLAIN_TEXT"] as const
export type PasswordMethod = typeof passwordMethods[number]
/**
* Used to determine the password method.
*
* There are three options for the return value:
* 1. "SHA256" -> the legacy hashing algorithm
* 2. "ARGON2" -> the newest hashing algorithm
* 3. "PLAIN_TEXT" -> regular ol' password with no hashing
*
* @returns {PasswordMethod} "SHA256" | "ARGON2" | "PLAIN_TEXT"
*/
export function getPasswordMethod(hashedPassword: string | undefined): PasswordMethod {
if (!hashedPassword) {
return "PLAIN_TEXT"
}
// This is the new hashing algorithm
if (hashedPassword.includes("$argon")) {
return "ARGON2"
}
// This is the legacy hashing algorithm
return "SHA256"
}
type PasswordValidation = {
isPasswordValid: boolean
hashedPassword: string
}
type HandlePasswordValidationArgs = {
/** The PasswordMethod */
passwordMethod: PasswordMethod
/** The password provided by the user */
passwordFromRequestBody: string
/** The password set in PASSWORD or config */
passwordFromArgs: string | undefined
/** The hashed-password set in HASHED_PASSWORD or config */
hashedPasswordFromArgs: string | undefined
}
/**
* Checks if a password is valid and also returns the hash
* using the PasswordMethod
*/
export async function handlePasswordValidation({
passwordMethod,
passwordFromArgs,
passwordFromRequestBody,
hashedPasswordFromArgs,
}: HandlePasswordValidationArgs): Promise<PasswordValidation> {
const passwordValidation = <PasswordValidation>{
isPasswordValid: false,
hashedPassword: "",
}
switch (passwordMethod) {
case "PLAIN_TEXT": {
const isValid = passwordFromArgs ? safeCompare(passwordFromRequestBody, passwordFromArgs) : false
passwordValidation.isPasswordValid = isValid
const hashedPassword = await hash(passwordFromRequestBody)
passwordValidation.hashedPassword = hashedPassword
break
}
case "SHA256": {
const isValid = isHashLegacyMatch(passwordFromRequestBody, hashedPasswordFromArgs || "")
passwordValidation.isPasswordValid = isValid
passwordValidation.hashedPassword = hashedPasswordFromArgs || (await hashLegacy(passwordFromRequestBody))
break
}
case "ARGON2": {
const isValid = await isHashMatch(passwordFromRequestBody, hashedPasswordFromArgs || "")
passwordValidation.isPasswordValid = isValid
passwordValidation.hashedPassword = hashedPasswordFromArgs || ""
break
}
default:
break
}
return passwordValidation
}
export type IsCookieValidArgs = {
passwordMethod: PasswordMethod
cookieKey: string
hashedPasswordFromArgs: string | undefined
passwordFromArgs: string | undefined
}
/** Checks if a req.cookies.key is valid using the PasswordMethod */
export async function isCookieValid({
passwordFromArgs = "",
cookieKey,
hashedPasswordFromArgs = "",
passwordMethod,
}: IsCookieValidArgs): Promise<boolean> {
let isValid = false
switch (passwordMethod) {
case "PLAIN_TEXT":
isValid = await isHashMatch(passwordFromArgs, cookieKey)
break
case "ARGON2":
case "SHA256":
isValid = safeCompare(cookieKey, hashedPasswordFromArgs)
break
default:
break
}
return isValid
}
/** Ensures that the input is sanitized by checking
* - it's a string
* - greater than 0 characters
* - trims whitespace
*/
export function sanitizeString(str: string): string {
// Very basic sanitization of string
// Credit: https://stackoverflow.com/a/46719000/3015595
return typeof str === "string" && str.trim().length > 0 ? str.trim() : ""
}
const mimeTypes: { [key: string]: string } = { const mimeTypes: { [key: string]: string } = {
".aac": "audio/x-aac", ".aac": "audio/x-aac",
".avi": "video/x-msvideo", ".avi": "video/x-msvideo",
......
...@@ -8,21 +8,21 @@ import { ...@@ -8,21 +8,21 @@ import {
Config, Config,
globalSetup, globalSetup,
} from "@playwright/test" } from "@playwright/test"
import * as crypto from "crypto" import * as argon2 from "argon2"
import path from "path" import path from "path"
import { PASSWORD } from "./utils/constants" import { PASSWORD } from "./utils/constants"
import * as wtfnode from "./utils/wtfnode" import * as wtfnode from "./utils/wtfnode"
// Playwright doesn't like that ../src/node/util has an enum in it // Playwright doesn't like that ../src/node/util has an enum in it
// so I had to copy hash in separately // so I had to copy hash in separately
const hash = (str: string): string => { const hash = async (str: string): Promise<string> => {
return crypto.createHash("sha256").update(str).digest("hex") return await argon2.hash(str)
} }
const cookieToStore = { const cookieToStore = {
sameSite: "Lax" as const, sameSite: "Lax" as const,
name: "key", name: "key",
value: hash(PASSWORD), value: "",
domain: "localhost", domain: "localhost",
path: "/", path: "/",
expires: -1, expires: -1,
...@@ -38,6 +38,8 @@ globalSetup(async () => { ...@@ -38,6 +38,8 @@ globalSetup(async () => {
wtfnode.setup() wtfnode.setup()
} }
cookieToStore.value = await hash(PASSWORD)
const storage = { const storage = {
cookies: [cookieToStore], cookies: [cookieToStore],
} }
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
"@types/jsdom": "^16.2.6", "@types/jsdom": "^16.2.6",
"@types/node-fetch": "^2.5.8", "@types/node-fetch": "^2.5.8",
"@types/supertest": "^2.0.10", "@types/supertest": "^2.0.10",
"argon2": "^0.28.0",
"jest": "^26.6.3", "jest": "^26.6.3",
"jsdom": "^16.4.0", "jsdom": "^16.4.0",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
......
...@@ -3,7 +3,7 @@ import { promises as fs } from "fs" ...@@ -3,7 +3,7 @@ import { promises as fs } from "fs"
import * as net from "net" import * as net from "net"
import * as os from "os" import * as os from "os"
import * as path from "path" import * as path from "path"
import { Args, parse, setDefaults, shouldOpenInExistingInstance } from "../../src/node/cli" import { Args, parse, setDefaults, shouldOpenInExistingInstance, splitOnFirstEquals } from "../../src/node/cli"
import { tmpdir } from "../../src/node/constants" import { tmpdir } from "../../src/node/constants"
import { paths } from "../../src/node/util" import { paths } from "../../src/node/util"
...@@ -306,7 +306,8 @@ describe("parser", () => { ...@@ -306,7 +306,8 @@ describe("parser", () => {
}) })
it("should use env var hashed password", async () => { it("should use env var hashed password", async () => {
process.env.HASHED_PASSWORD = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" // test process.env.HASHED_PASSWORD =
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY" // test
const args = parse([]) const args = parse([])
expect(args).toEqual({ expect(args).toEqual({
_: [], _: [],
...@@ -316,7 +317,8 @@ describe("parser", () => { ...@@ -316,7 +317,8 @@ describe("parser", () => {
expect(defaultArgs).toEqual({ expect(defaultArgs).toEqual({
...defaults, ...defaults,
_: [], _: [],
"hashed-password": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "hashed-password":
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
usingEnvHashedPassword: true, usingEnvHashedPassword: true,
}) })
}) })
...@@ -335,6 +337,33 @@ describe("parser", () => { ...@@ -335,6 +337,33 @@ describe("parser", () => {
"proxy-domain": ["coder.com", "coder.org"], "proxy-domain": ["coder.com", "coder.org"],
}) })
}) })
it("should allow '=,$/' in strings", async () => {
const args = parse([
"--enable-proposed-api",
"$argon2i$v=19$m=4096,t=3,p=1$0qr/o+0t00hsbjfqcksfdq$ofcm4rl6o+b7oxpua4qlxubypbbpsf+8l531u7p9hyy",
])
expect(args).toEqual({
_: [],
"enable-proposed-api": [
"$argon2i$v=19$m=4096,t=3,p=1$0qr/o+0t00hsbjfqcksfdq$ofcm4rl6o+b7oxpua4qlxubypbbpsf+8l531u7p9hyy",
],
})
})
it("should parse options with double-dash and multiple equal signs ", async () => {
const args = parse(
[
"--hashed-password=$argon2i$v=19$m=4096,t=3,p=1$0qr/o+0t00hsbjfqcksfdq$ofcm4rl6o+b7oxpua4qlxubypbbpsf+8l531u7p9hyy",
],
{
configFile: "/pathtoconfig",
},
)
expect(args).toEqual({
_: [],
"hashed-password":
"$argon2i$v=19$m=4096,t=3,p=1$0qr/o+0t00hsbjfqcksfdq$ofcm4rl6o+b7oxpua4qlxubypbbpsf+8l531u7p9hyy",
})
})
}) })
describe("cli", () => { describe("cli", () => {
...@@ -409,3 +438,28 @@ describe("cli", () => { ...@@ -409,3 +438,28 @@ describe("cli", () => {
expect(await shouldOpenInExistingInstance(args)).toStrictEqual(undefined) expect(await shouldOpenInExistingInstance(args)).toStrictEqual(undefined)
}) })
}) })
describe("splitOnFirstEquals", () => {
it("should split on the first equals", () => {
const testStr = "enabled-proposed-api=test=value"
const actual = splitOnFirstEquals(testStr)
const expected = ["enabled-proposed-api", "test=value"]
expect(actual).toEqual(expect.arrayContaining(expected))
})
it("should split on first equals regardless of multiple equals signs", () => {
const testStr =
"hashed-password=$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY"
const actual = splitOnFirstEquals(testStr)
const expected = [
"hashed-password",
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
]
expect(actual).toEqual(expect.arrayContaining(expected))
})
it("should always return the first element before an equals", () => {
const testStr = "auth="
const actual = splitOnFirstEquals(testStr)
const expected = ["auth"]
expect(actual).toEqual(expect.arrayContaining(expected))
})
})
import {
hash,
isHashMatch,
handlePasswordValidation,
PasswordMethod,
getPasswordMethod,
hashLegacy,
isHashLegacyMatch,
isCookieValid,
sanitizeString,
} from "../../../src/node/util"
describe("getEnvPaths", () => { describe("getEnvPaths", () => {
describe("on darwin", () => { describe("on darwin", () => {
let ORIGINAL_PLATFORM = "" let ORIGINAL_PLATFORM = ""
...@@ -145,3 +157,253 @@ describe("getEnvPaths", () => { ...@@ -145,3 +157,253 @@ describe("getEnvPaths", () => {
}) })
}) })
}) })
describe("hash", () => {
it("should return a hash of the string passed in", async () => {
const plainTextPassword = "mySecretPassword123"
const hashed = await hash(plainTextPassword)
expect(hashed).not.toBe(plainTextPassword)
})
})
describe("isHashMatch", () => {
it("should return true if the password matches the hash", async () => {
const password = "codeserver1234"
const _hash = await hash(password)
const actual = await isHashMatch(password, _hash)
expect(actual).toBe(true)
})
it("should return false if the password does not match the hash", async () => {
const password = "password123"
const _hash = await hash(password)
const actual = await isHashMatch("otherPassword123", _hash)
expect(actual).toBe(false)
})
it("should return true with actual hash", async () => {
const password = "password123"
const _hash = "$argon2i$v=19$m=4096,t=3,p=1$EAoczTxVki21JDfIZpTUxg$rkXgyrW4RDGoDYrxBFD4H2DlSMEhP4h+Api1hXnGnFY"
const actual = await isHashMatch(password, _hash)
expect(actual).toBe(true)
})
it("should return false if the password is empty", async () => {
const password = ""
const _hash = "$argon2i$v=19$m=4096,t=3,p=1$EAoczTxVki21JDfIZpTUxg$rkXgyrW4RDGoDYrxBFD4H2DlSMEhP4h+Api1hXnGnFY"
const actual = await isHashMatch(password, _hash)
expect(actual).toBe(false)
})
it("should return false if the hash is empty", async () => {
const password = "hellowpasssword"
const _hash = ""
const actual = await isHashMatch(password, _hash)
expect(actual).toBe(false)
})
})
describe("hashLegacy", () => {
it("should return a hash of the string passed in", () => {
const plainTextPassword = "mySecretPassword123"
const hashed = hashLegacy(plainTextPassword)
expect(hashed).not.toBe(plainTextPassword)
})
})
describe("isHashLegacyMatch", () => {
it("should return true if is match", () => {
const password = "password123"
const _hash = hashLegacy(password)
expect(isHashLegacyMatch(password, _hash)).toBe(true)
})
it("should return false if is match", () => {
const password = "password123"
const _hash = hashLegacy(password)
expect(isHashLegacyMatch("otherPassword123", _hash)).toBe(false)
})
it("should return true if hashed from command line", () => {
const password = "password123"
// Hashed using printf "password123" | sha256sum | cut -d' ' -f1
const _hash = "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
expect(isHashLegacyMatch(password, _hash)).toBe(true)
})
})
describe("getPasswordMethod", () => {
it("should return PLAIN_TEXT for no hashed password", () => {
const hashedPassword = undefined
const passwordMethod = getPasswordMethod(hashedPassword)
const expected: PasswordMethod = "PLAIN_TEXT"
expect(passwordMethod).toEqual(expected)
})
it("should return ARGON2 for password with 'argon2'", () => {
const hashedPassword =
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY"
const passwordMethod = getPasswordMethod(hashedPassword)
const expected: PasswordMethod = "ARGON2"
expect(passwordMethod).toEqual(expected)
})
it("should return SHA256 for password with legacy hash", () => {
const hashedPassword = "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af"
const passwordMethod = getPasswordMethod(hashedPassword)
const expected: PasswordMethod = "SHA256"
expect(passwordMethod).toEqual(expected)
})
})
describe("handlePasswordValidation", () => {
it("should return true with a hashedPassword for a PLAIN_TEXT password", async () => {
const p = "password"
const passwordValidation = await handlePasswordValidation({
passwordMethod: "PLAIN_TEXT",
passwordFromRequestBody: p,
passwordFromArgs: p,
hashedPasswordFromArgs: undefined,
})
const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword)
expect(passwordValidation.isPasswordValid).toBe(true)
expect(matchesHash).toBe(true)
})
it("should return false when PLAIN_TEXT password doesn't match args", async () => {
const p = "password"
const passwordValidation = await handlePasswordValidation({
passwordMethod: "PLAIN_TEXT",
passwordFromRequestBody: "password1",
passwordFromArgs: p,
hashedPasswordFromArgs: undefined,
})
const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword)
expect(passwordValidation.isPasswordValid).toBe(false)
expect(matchesHash).toBe(false)
})
it("should return true with a hashedPassword for a SHA256 password", async () => {
const p = "helloworld"
const passwordValidation = await handlePasswordValidation({
passwordMethod: "SHA256",
passwordFromRequestBody: p,
passwordFromArgs: undefined,
hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af",
})
const matchesHash = isHashLegacyMatch(p, passwordValidation.hashedPassword)
expect(passwordValidation.isPasswordValid).toBe(true)
expect(matchesHash).toBe(true)
})
it("should return false when SHA256 password doesn't match hash", async () => {
const p = "helloworld1"
const passwordValidation = await handlePasswordValidation({
passwordMethod: "SHA256",
passwordFromRequestBody: p,
passwordFromArgs: undefined,
hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af",
})
const matchesHash = isHashLegacyMatch(p, passwordValidation.hashedPassword)
expect(passwordValidation.isPasswordValid).toBe(false)
expect(matchesHash).toBe(false)
})
it("should return true with a hashedPassword for a ARGON2 password", async () => {
const p = "password"
const passwordValidation = await handlePasswordValidation({
passwordMethod: "ARGON2",
passwordFromRequestBody: p,
passwordFromArgs: undefined,
hashedPasswordFromArgs:
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
})
const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword)
expect(passwordValidation.isPasswordValid).toBe(true)
expect(matchesHash).toBe(true)
})
it("should return false when ARGON2 password doesn't match hash", async () => {
const p = "password1"
const passwordValidation = await handlePasswordValidation({
passwordMethod: "ARGON2",
passwordFromRequestBody: p,
passwordFromArgs: undefined,
hashedPasswordFromArgs:
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
})
const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword)
expect(passwordValidation.isPasswordValid).toBe(false)
expect(matchesHash).toBe(false)
})
})
describe("isCookieValid", () => {
it("should be valid if hashed-password for SHA256 matches cookie.key", async () => {
const isValid = await isCookieValid({
passwordMethod: "SHA256",
cookieKey: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af",
hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af",
passwordFromArgs: undefined,
})
expect(isValid).toBe(true)
})
it("should be invalid if hashed-password for SHA256 does not match cookie.key", async () => {
const isValid = await isCookieValid({
passwordMethod: "SHA256",
cookieKey: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb9442bb6f8f8f07af",
hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af",
passwordFromArgs: undefined,
})
expect(isValid).toBe(false)
})
it("should be valid if hashed-password for ARGON2 matches cookie.key", async () => {
const isValid = await isCookieValid({
passwordMethod: "ARGON2",
cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
hashedPasswordFromArgs:
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
passwordFromArgs: undefined,
})
expect(isValid).toBe(true)
})
it("should be invalid if hashed-password for ARGON2 does not match cookie.key", async () => {
const isValid = await isCookieValid({
passwordMethod: "ARGON2",
cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9H",
hashedPasswordFromArgs:
"$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
passwordFromArgs: undefined,
})
expect(isValid).toBe(false)
})
it("should be valid if password for PLAIN_TEXT matches cookie.key", async () => {
const isValid = await isCookieValid({
passwordMethod: "PLAIN_TEXT",
cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY",
passwordFromArgs: "password",
hashedPasswordFromArgs: undefined,
})
expect(isValid).toBe(true)
})
it("should be invalid if hashed-password for PLAIN_TEXT does not match cookie.key", async () => {
const isValid = await isCookieValid({
passwordMethod: "PLAIN_TEXT",
cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9H",
passwordFromArgs: "password1234",
hashedPasswordFromArgs: undefined,
})
expect(isValid).toBe(false)
})
})
describe("sanitizeString", () => {
it("should return an empty string if passed a type other than a string", () => {
expect(sanitizeString({} as string)).toBe("")
})
it("should trim whitespace", () => {
expect(sanitizeString(" hello ")).toBe("hello")
})
it("should always return an empty string", () => {
expect(sanitizeString(" ")).toBe("")
})
})
此差异已折叠。
...@@ -145,12 +145,16 @@ export const proxy: ProxyServer ...@@ -145,12 +145,16 @@ export const proxy: ProxyServer
/** /**
* Middleware to ensure the user is authenticated. Throws if they are not. * Middleware to ensure the user is authenticated. Throws if they are not.
*/ */
export function ensureAuthenticated(req: express.Request, res?: express.Response, next?: express.NextFunction): void export function ensureAuthenticated(
req: express.Request,
res?: express.Response,
next?: express.NextFunction,
): Promise<void>
/** /**
* Returns true if the user is authenticated. * Returns true if the user is authenticated.
*/ */
export function authenticated(req: express.Request): boolean export function authenticated(req: express.Request): Promise<void>
/** /**
* Replace variables in HTML: TO, BASE, CS_STATIC_BASE, and OPTIONS. * Replace variables in HTML: TO, BASE, CS_STATIC_BASE, and OPTIONS.
......
此差异已折叠。
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册