diff --git a/doc/guide.md b/doc/guide.md index ce17a361..46abd083 100644 --- a/doc/guide.md +++ b/doc/guide.md @@ -297,6 +297,9 @@ and then restart `code-server` with: sudo systemctl restart code-server@$USER ``` +Alternatively, you can specify the SHA-256 of your password at the `hashedPassword` field in the config file. +The `hashedPassword` field takes precedence over `password`. + ### How do I securely access development web services? If you're working on a web service and want to access it locally, `code-server` can proxy it for you. diff --git a/src/node/cli.ts b/src/node/cli.ts index 571f3958..b3017b66 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -29,6 +29,7 @@ export interface Args extends VsArgs { config?: string auth?: AuthType password?: string + hashedPassword?: string cert?: OptionalString "cert-host"?: string "cert-key"?: string @@ -104,6 +105,12 @@ const options: Options> = { type: "string", description: "The password for password authentication (can only be passed in via $PASSWORD or the config file).", }, + hashedPassword: { + 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" + + "Takes precedence over 'password'.", + }, cert: { type: OptionalString, path: true, @@ -279,6 +286,10 @@ export const parse = ( throw new Error("--password can only be set in the config file or passed in via $PASSWORD") } + if (key === "hashedPassword" && !opts?.configFile) { + throw new Error("--hashedPassword can only be set in the config file or passed in via $HASHED_PASSWORD") + } + const option = options[key] if (option.type === "boolean") { ;(args[key] as boolean) = true @@ -361,6 +372,7 @@ export interface DefaultedArgs extends ConfigArgs { "proxy-domain": string[] verbose: boolean usingEnvPassword: boolean + usingEnvHashedPassword: boolean "extensions-dir": string "user-data-dir": string } @@ -448,13 +460,20 @@ export async function setDefaults(cliArgs: Args, configArgs?: ConfigArgs): Promi args["cert-key"] = certKey } - const usingEnvPassword = !!process.env.PASSWORD + let usingEnvPassword = !!process.env.PASSWORD if (process.env.PASSWORD) { args.password = process.env.PASSWORD } - // Ensure it's not readable by child processes. + const usingEnvHashedPassword = !!process.env.HASHED_PASSWORD + if (process.env.HASHED_PASSWORD) { + args.hashedPassword = process.env.HASHED_PASSWORD + usingEnvPassword = false + } + + // Ensure they're not readable by child processes. delete process.env.PASSWORD + delete process.env.HASHED_PASSWORD // Filter duplicate proxy domains and remove any leading `*.`. const proxyDomains = new Set((args["proxy-domain"] || []).map((d) => d.replace(/^\*\./, ""))) @@ -463,6 +482,7 @@ export async function setDefaults(cliArgs: Args, configArgs?: ConfigArgs): Promi return { ...args, usingEnvPassword, + usingEnvHashedPassword, } as DefaultedArgs // TODO: Technically no guarantee this is fulfilled. } diff --git a/src/node/entry.ts b/src/node/entry.ts index 4e9a6a9f..ac615da6 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -99,8 +99,10 @@ const main = async (args: DefaultedArgs): Promise => { logger.info(`Using user-data-dir ${humanPath(args["user-data-dir"])}`) logger.trace(`Using extensions-dir ${humanPath(args["extensions-dir"])}`) - if (args.auth === AuthType.Password && !args.password) { - throw new Error("Please pass in a password via the config file or $PASSWORD") + if (args.auth === AuthType.Password && !args.password && !args.hashedPassword) { + throw new Error( + "Please pass in a password via the config file or environment variable ($PASSWORD or $HASHED_PASSWORD)", + ) } const [app, wsApp, server] = await createApp(args) @@ -114,6 +116,8 @@ const main = async (args: DefaultedArgs): Promise => { logger.info(" - Authentication is enabled") if (args.usingEnvPassword) { logger.info(" - Using password from $PASSWORD") + } else if (args.usingEnvHashedPassword) { + logger.info(" - Using password from $HASHED_PASSWORD") } else { logger.info(` - Using password from ${humanPath(args.config)}`) } diff --git a/src/node/http.ts b/src/node/http.ts index 1aa7adb5..72d6d391 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -52,7 +52,12 @@ export const authenticated = (req: express.Request): boolean => { return true case AuthType.Password: // The password is stored in the cookie after being hashed. - return req.args.password && req.cookies.key && safeCompare(req.cookies.key, hash(req.args.password)) + return !!( + req.cookies.key && + (req.args.hashedPassword + ? safeCompare(req.cookies.key, req.args.hashedPassword) + : req.args.password && safeCompare(req.cookies.key, hash(req.args.password))) + ) default: throw new Error(`Unsupported auth type ${req.args.auth}`) } diff --git a/src/node/routes/login.ts b/src/node/routes/login.ts index bf1058e5..4db7fd82 100644 --- a/src/node/routes/login.ts +++ b/src/node/routes/login.ts @@ -30,6 +30,8 @@ const getRoot = async (req: Request, error?: Error): Promise => { let passwordMsg = `Check the config file at ${humanPath(req.args.config)} for the password.` if (req.args.usingEnvPassword) { passwordMsg = "Password was set from $PASSWORD." + } else if (req.args.usingEnvHashedPassword) { + passwordMsg = "Password was set from $HASHED_PASSWORD." } return replaceTemplates( req, @@ -65,7 +67,11 @@ router.post("/", async (req, res) => { throw new Error("Missing password") } - if (req.args.password && safeCompare(req.body.password, req.args.password)) { + if ( + req.args.hashedPassword + ? safeCompare(hash(req.body.password), req.args.hashedPassword) + : req.args.password && safeCompare(req.body.password, req.args.password) + ) { // 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), { diff --git a/test/cli.test.ts b/test/cli.test.ts index 6b1e96c2..6e22e65a 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -26,6 +26,7 @@ describe("parser", () => { port: 8080, "proxy-domain": [], usingEnvPassword: false, + usingEnvHashedPassword: false, "extensions-dir": path.join(paths.data, "extensions"), "user-data-dir": paths.data, } @@ -290,6 +291,21 @@ describe("parser", () => { }) }) + it("should use env var hashed password", async () => { + process.env.HASHED_PASSWORD = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" // test + const args = parse([]) + assert.deepEqual(args, { + _: [], + }) + + assert.deepEqual(await setDefaults(args), { + ...defaults, + _: [], + hashedPassword: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + usingEnvHashedPassword: true, + }) + }) + it("should filter proxy domains", async () => { const args = parse(["--proxy-domain", "*.coder.com", "--proxy-domain", "coder.com", "--proxy-domain", "coder.org"]) assert.deepEqual(args, {