-
Notifications
You must be signed in to change notification settings - Fork 5.7k
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
Changes from all commits
dd2cb16
cac6673
17be8c5
f35120c
aaf0447
fc3326f
dc2db5c
51f8341
70197bb
fd3cb6c
fcc3f0d
0cdbd33
91303d4
1134780
788b958
ffa5c16
7ff4117
a14ea39
409b473
6020480
923761c
517aaf7
531b7c0
8c2bb61
deaa224
3b50bfc
1e55a64
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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) { | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
I completely understand if your style is to not extract things into separate functions. I will take this comment as a There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}`) | ||
} | ||
} | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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", | ||
|
@@ -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() | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👀 Major change There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I'm really glad you mention that and agree.
Also agree!
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! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
|
There was a problem hiding this comment.
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 🤷♂️
There was a problem hiding this comment.
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).
MDN docs
There was a problem hiding this comment.
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
intokey
=hashed-password
andvalue
=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.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#1908