Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: use sufficient computational effort for password hash #3422

Merged
merged 27 commits into from
Jun 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dd2cb16
chore: update CHANGELOG
jsjoeio May 19, 2021
cac6673
refactor: use bcrypt in hash function
jsjoeio May 19, 2021
17be8c5
refactor: use bcrypt in e2e setup
jsjoeio May 19, 2021
f35120c
feat: add unit test for hash function
jsjoeio May 19, 2021
aaf0447
refactor: add functions to check hash password
jsjoeio May 19, 2021
fc3326f
feat: add tests using real hashes
jsjoeio May 20, 2021
dc2db5c
chore: add argon2 package
jsjoeio Jun 2, 2021
51f8341
chore: update to argon2 in test
jsjoeio Jun 2, 2021
70197bb
refactor: use argon2 instead of bcrypt
jsjoeio Jun 2, 2021
fd3cb6c
refactor: update unit tests for hash fns
jsjoeio Jun 2, 2021
fcc3f0d
refactor: update login logic with new async hashing
jsjoeio Jun 2, 2021
0cdbd33
refactor: make authenticated async everywhere
jsjoeio Jun 2, 2021
91303d4
refactor: make ensureAuthenticated async
jsjoeio Jun 2, 2021
1134780
refactor: make wsProxy async
jsjoeio Jun 2, 2021
788b958
refactor: update hash fn in test config
jsjoeio Jun 2, 2021
ffa5c16
feat: update cli and test for hashed-password
jsjoeio Jun 2, 2021
7ff4117
feat: add getPasswordMethod & test for it
jsjoeio Jun 2, 2021
a14ea39
feat: add handlePasswordValidation + tests
jsjoeio Jun 2, 2021
409b473
refactor: rewrite password logic at /login
jsjoeio Jun 2, 2021
6020480
feat: add isCookieValid function and tests
jsjoeio Jun 3, 2021
923761c
refactor: password logic in http w/ isCookieValid
jsjoeio Jun 3, 2021
517aaf7
docs: update FAQ with new hashing instructions
jsjoeio Jun 3, 2021
531b7c0
feat: add splitOnFirstEquals function
jsjoeio Jun 3, 2021
8c2bb61
refactor: parse options with multiple = in cli
jsjoeio Jun 3, 2021
deaa224
feat: add npm_config_build_from_source to build scripts
jsjoeio Jun 7, 2021
3b50bfc
fix: sanitize password and cookie key
jsjoeio Jun 7, 2021
1e55a64
feat: check for empty str in isHashMatch
jsjoeio Jun 7, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ VS Code v0.00.0
- chore: cross-compile docker images with buildx #3166 @oxy
- chore: update node to v14 #3458 @oxy
- chore: update .gitignore #3557 @cuining
- fix: use sufficient computational effort for password hash #3422 @jsjoeio

### Development

Expand Down
4 changes: 4 additions & 0 deletions ci/build/build-standalone-release.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
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() {
cd "$(dirname "${0}")/../.."
source ./ci/lib.sh
Expand Down
3 changes: 3 additions & 0 deletions ci/build/npm-postinstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ 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() {
# Grabs the major version of node from $npm_config_user_agent which looks like
Expand Down
9 changes: 5 additions & 4 deletions docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
printf "thisismypassword" | sha256sum | cut -d' ' -f1
```shell
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:

```yaml
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?
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
},
"dependencies": {
"@coder/logger": "1.1.16",
"argon2": "^0.28.0",
"body-parser": "^1.19.0",
"compression": "^1.7.4",
"cookie-parser": "^1.4.5",
Expand Down
18 changes: 16 additions & 2 deletions src/node/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const options: Options<Required<Args>> = {
"hashed-password": {
type: "string",
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'.",
},
cert: {
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggest using a regex match rather than a split, so that you can validate the argon2i part and other parameters too? this can sometimes be clearer than string splits, but of course requires whoever is reading the code to be familiar with regular expressions... tradeoffs 🤷‍♂️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am probably misunderstanding what you mean but I don't think that is what we want. (though I agree string splits are hard to read).

We need the value to be split on the first equals and I don't think using a regex match would give us the same result (based on this quick test).

image

If separator is a regular expression with capturing parentheses, then each time separator matches, the results (including any undefined results) of the capturing parentheses are spliced into the output array.

MDN docs

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh, I see, I misunderstood what this code is doing. I guess it's because we wrote our own argument parser, and now need a way to parse: --hashed-password=abc=def into key = hashed-password and value = abc=def

Is there some reason we're implementing our own parser rather than relying on a library like yargs? That's too big of a change for this PR, but maybe we can consider adopting a library in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some reason we're implementing our own parser rather than relying on a library like yargs?

Fantastic question! All I can say is "legacy" 😛

But in all seriousness, I agree with you. Too big for this PR but something we want to do. I know @oxy has mentioned it multiple times. Hoping we get to it eventually!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


return split
}

export const parse = (
argv: string[],
opts?: {
Expand All @@ -250,6 +263,7 @@ export const parse = (
if (opts?.configFile) {
msg = `error reading ${opts.configFile}: ${msg}`
}

return new Error(msg)
}

Expand All @@ -270,7 +284,7 @@ export const parse = (
let key: keyof Args | undefined
let value: string | undefined
if (arg.startsWith("--")) {
const split = arg.replace(/^--/, "").split("=", 2)
const split = splitOnFirstEquals(arg.replace(/^--/, ""))
key = split[0] as keyof Args
value = split[1]
} else {
Expand Down
39 changes: 25 additions & 14 deletions src/node/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { field, logger } from "@coder/logger"
import * as express from "express"
import * as expressCore from "express-serve-static-core"
import qs from "qs"
import safeCompare from "safe-compare"
import { HttpCode, HttpError } from "../common/http"
import { normalize, Options } from "../common/util"
import { AuthType, DefaultedArgs } from "./cli"
import { commit, rootPath } from "./constants"
import { Heart } from "./heart"
import { hash } from "./util"
import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString } from "./util"

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
Expand Down Expand Up @@ -45,8 +44,13 @@ export const replaceTemplates = <T extends object>(
/**
* Throw an error if not authorized. Call `next` if provided.
*/
export const ensureAuthenticated = (req: express.Request, _?: express.Response, next?: express.NextFunction): void => {
if (!authenticated(req)) {
export const ensureAuthenticated = async (
req: express.Request,
_?: express.Response,
next?: express.NextFunction,
): Promise<void> => {
const isAuthenticated = await authenticated(req)
if (!isAuthenticated) {
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
}
if (next) {
Expand All @@ -57,20 +61,27 @@ export const ensureAuthenticated = (req: express.Request, _?: express.Response,
/**
* 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) {
case AuthType.None:
case AuthType.None: {
return true
case AuthType.Password:
}
case AuthType.Password: {
// The password is stored in the cookie after being hashed.
return !!(
req.cookies.key &&
(req.args["hashed-password"]
? safeCompare(req.cookies.key, req.args["hashed-password"])
: req.args.password && safeCompare(req.cookies.key, hash(req.args.password)))
)
default:
const hashedPasswordFromArgs = req.args["hashed-password"]
const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
const isCookieValidArgs: IsCookieValidArgs = {
passwordMethod,
cookieKey: sanitizeString(req.cookies.key),
passwordFromArgs: req.args.password || "",
hashedPasswordFromArgs: req.args["hashed-password"],
}

return await isCookieValid(isCookieValidArgs)
Comment on lines +73 to +80
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we're passing the cookie stuff into a separate function instead of handling it in authenticated? All paths seem to lead here, so I don't know why we folded that out into a separate function,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are definitely multiple approaches one could take for this.

I went with this approach because it's:

  • testable (rather than having to mock requests and test authenticated, though we will get there at one point)
  • easy to read -> isCookieValid makes it very clear what the functionality is and what we're looking for
  • maintainable -> should we move to a token approach, we can make changes to the underlying function and test in isolation

I completely understand if your style is to not extract things into separate functions.

I will take this comment as a nit since it doesn't seem like there is much we're discussing besides the style/approach I took to solving the problem (unless I'm missing something).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way we have login tests that make real requests so testing that should be pretty easy!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh fantastic! I didn't even realize. That will be easy then!

}
default: {
throw new Error(`Unsupported auth type ${req.args.auth}`)
}
}
}

Expand Down
9 changes: 5 additions & 4 deletions src/node/routes/domainProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ const maybeProxy = (req: Request): string | undefined => {
return port
}

router.all("*", (req, res, next) => {
router.all("*", async (req, res, next) => {
const port = maybeProxy(req)
if (!port) {
return next()
}

// 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.
if (req.path.startsWith("/static/") && req.method === "GET") {
return next()
Expand Down Expand Up @@ -73,14 +74,14 @@ router.all("*", (req, res, next) => {

export const wsRouter = WsRouter()

wsRouter.ws("*", (req, _, next) => {
wsRouter.ws("*", async (req, _, next) => {
const port = maybeProxy(req)
if (!port) {
return next()
}

// Must be authenticated to use the proxy.
ensureAuthenticated(req)
await ensureAuthenticated(req)

proxy.ws(req, req.ws, req.head, {
ignorePath: true,
Expand Down
8 changes: 4 additions & 4 deletions src/node/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ export const register = async (
app.all("/proxy/(:port)(/*)?", (req, res) => {
pathProxy.proxy(req, res)
})
wsApp.get("/proxy/(:port)(/*)?", (req) => {
pathProxy.wsProxy(req as pluginapi.WebsocketRequest)
wsApp.get("/proxy/(:port)(/*)?", async (req) => {
await pathProxy.wsProxy(req as pluginapi.WebsocketRequest)
})
// These two routes pass through the path directly.
// So the proxied app must be aware it is running
Expand All @@ -109,8 +109,8 @@ export const register = async (
passthroughPath: true,
})
})
wsApp.get("/absproxy/(:port)(/*)?", (req) => {
pathProxy.wsProxy(req as pluginapi.WebsocketRequest, {
wsApp.get("/absproxy/(:port)(/*)?", async (req) => {
await pathProxy.wsProxy(req as pluginapi.WebsocketRequest, {
passthroughPath: true,
})
})
Expand Down
28 changes: 17 additions & 11 deletions src/node/routes/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import { Router, Request } from "express"
import { promises as fs } from "fs"
import { RateLimiter as Limiter } from "limiter"
import * as path from "path"
import safeCompare from "safe-compare"
import { rootPath } from "../constants"
import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http"
import { hash, humanPath } from "../util"
import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString } from "../util"

export enum Cookie {
Key = "key",
Expand Down Expand Up @@ -49,9 +48,9 @@ const limiter = new RateLimiter()

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) || "/"
if (authenticated(req)) {
if (await authenticated(req)) {
return redirect(req, res, to, { to: undefined })
}
next()
Expand All @@ -62,24 +61,31 @@ router.get("/", async (req, res) => {
})

router.post("/", async (req, res) => {
const password = sanitizeString(req.body.password)
const hashedPasswordFromArgs = req.args["hashed-password"]

try {
// Check to see if they exceeded their login attempts
if (!limiter.canTry()) {
throw new Error("Login rate limited!")
}

if (!req.body.password) {
if (!password) {
throw new Error("Missing password")
}

if (
req.args["hashed-password"]
? safeCompare(hash(req.body.password), req.args["hashed-password"])
: req.args.password && safeCompare(req.body.password, req.args.password)
) {
const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
const { isPasswordValid, hashedPassword } = await handlePasswordValidation({
passwordMethod,
hashedPasswordFromArgs,
passwordFromRequestBody: password,
passwordFromArgs: req.args.password,
})

if (isPasswordValid) {
// The hash does not add any actual security but we do it for
// obfuscation purposes (and as a side effect it handles escaping).
res.cookie(Cookie.Key, hash(req.body.password), {
res.cookie(Cookie.Key, hashedPassword, {
Comment on lines +77 to +88
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Major change

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While hashing is a major step forward, the problem with this approach is that it still allows attackers who have access to the hash to just submit it as-is and gain access to code-server - effectively not very different from storing it in plaintext.

We should definitely look into replacing this with something more robust so that password hashing isn't just a placebo - now or in a future version is up to you, but IMO we should tackle this before asking users to upgrade to Argon2.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

effectively not very different from storing it in plaintext.

I'm really glad you mention that and agree.

We should definitely look into replacing this with something more robust so that password hashing isn't just a placebo

Also agree!

now or in a future version is up to you, but IMO we should tackle this before asking users to upgrade to Argon2

I would argue that it falls out of the scope of this PR. If we close this PR without merging, we're in the same situation we're discussing right now (see code) because we're doing the same thing - hashing the password and storing as a cookie.

The scope of this PR was to replace the hashing algorithm (not to fix the problem with attackers who have the hash).

Therefore, I think it makes sense to separate the two concerns and tackle them in separate PRs. I will create a new issue to track that though!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally get it. Sometimes PRs bring in a lot of heat just because they touch an area that would otherwise have a lot of attention drawn towards. This one specifically has allowed us to become aware of other things in the codebase we want to change.

🙏

domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]),
path: req.body.base || "/",
sameSite: "lax",
Expand Down
6 changes: 3 additions & 3 deletions src/node/routes/pathProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ export function proxy(
})
}

export function wsProxy(
export async function wsProxy(
req: pluginapi.WebsocketRequest,
opts?: {
passthroughPath?: boolean
},
): void {
ensureAuthenticated(req)
): Promise<void> {
await ensureAuthenticated(req)
_proxy.ws(req, req.ws, req.head, {
ignorePath: true,
target: getProxyTarget(req, opts?.passthroughPath),
Expand Down
5 changes: 3 additions & 2 deletions src/node/routes/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ router.get("/(:commit)(/*)?", async (req, res) => {
// Used by VS Code to load extensions into the web worker.
const tar = getFirstString(req.query.tar)
if (tar) {
ensureAuthenticated(req)
await ensureAuthenticated(req)
let stream: Readable = tarFs.pack(pathToFsPath(tar))
if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) {
logger.debug("gzipping tar", field("path", tar))
Expand All @@ -43,7 +43,8 @@ router.get("/(:commit)(/*)?", async (req, res) => {

// Make sure it's in code-server if you aren't authenticated. This lets
// 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)
}

Expand Down
3 changes: 2 additions & 1 deletion src/node/routes/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export const router = Router()
const vscode = new VscodeProvider()

router.get("/", async (req, res) => {
if (!authenticated(req)) {
const isAuthenticated = await authenticated(req)
if (!isAuthenticated) {
return redirect(req, res, "login", {
// req.baseUrl can be blank if already at the root.
to: req.baseUrl && req.baseUrl !== "/" ? req.baseUrl : undefined,
Expand Down
Loading