Archived
1
0

Proxy path fixes (#4548)

* Fix issue where HTTP error status codes are not read.

* Fix issues surrounding sessions when accessed from a proxy.

- Updated vscode args to match latest upstream.
- Fixed issues surrounding trailing slashes affecting base paths.
- Updated cookie names to better match upstream's usage, debuggability.

* Bump vendor.

* Update tests.

* Fix issue where tests lack cookie key.

Co-authored-by: Asher <ash@coder.com>
This commit is contained in:
Teffen 2021-12-01 19:21:52 -05:00 committed by GitHub
parent 6a2740f57e
commit 62b3a6fd9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 39 additions and 27 deletions

View File

@ -13,8 +13,12 @@ export enum HttpCode {
* used in the HTTP response. * used in the HTTP response.
*/ */
export class HttpError extends Error { export class HttpError extends Error {
public constructor(message: string, public readonly status: HttpCode, public readonly details?: object) { public constructor(message: string, public readonly statusCode: HttpCode, public readonly details?: object) {
super(message) super(message)
this.name = this.constructor.name this.name = this.constructor.name
} }
} }
export enum CookieKeys {
Session = "code-server-session",
}

View File

@ -6,7 +6,7 @@ import * as net from "net"
import path from "path" import path from "path"
import qs from "qs" import qs from "qs"
import { Disposable } from "../common/emitter" import { Disposable } from "../common/emitter"
import { HttpCode, HttpError } from "../common/http" import { CookieKeys, HttpCode, HttpError } from "../common/http"
import { normalize } from "../common/util" import { normalize } from "../common/util"
import { AuthType, DefaultedArgs } from "./cli" import { AuthType, DefaultedArgs } from "./cli"
import { version as codeServerVersion } from "./constants" import { version as codeServerVersion } from "./constants"
@ -93,7 +93,7 @@ export const authenticated = async (req: express.Request): Promise<boolean> => {
const passwordMethod = getPasswordMethod(hashedPasswordFromArgs) const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
const isCookieValidArgs: IsCookieValidArgs = { const isCookieValidArgs: IsCookieValidArgs = {
passwordMethod, passwordMethod,
cookieKey: sanitizeString(req.cookies.key), cookieKey: sanitizeString(req.cookies[CookieKeys.Session]),
passwordFromArgs: req.args.password || "", passwordFromArgs: req.args.password || "",
hashedPasswordFromArgs: req.args["hashed-password"], hashedPasswordFromArgs: req.args["hashed-password"],
} }

View File

@ -1,6 +1,6 @@
import { field, logger } from "@coder/logger" import { field, logger } from "@coder/logger"
import * as os from "os"
import http from "http" import http from "http"
import * as os from "os"
import path from "path" import path from "path"
import { Disposable } from "../common/emitter" import { Disposable } from "../common/emitter"
import { plural } from "../common/util" import { plural } from "../common/util"
@ -37,8 +37,11 @@ export const runVsCodeCli = async (args: DefaultedArgs): Promise<void> => {
try { try {
await spawnCli({ await spawnCli({
...args, ...args,
// For some reason VS Code takes the port as a string. /** Type casting. */
port: typeof args.port !== "undefined" ? args.port.toString() : undefined, "accept-server-license-terms": true,
help: !!args.help,
version: !!args.version,
port: args.port?.toString(),
}) })
} catch (error: any) { } catch (error: any) {
logger.error("Got error from VS Code", error) logger.error("Got error from VS Code", error)

View File

@ -3,14 +3,11 @@ import { promises as fs } from "fs"
import { RateLimiter as Limiter } from "limiter" import { RateLimiter as Limiter } from "limiter"
import * as os from "os" import * as os from "os"
import * as path from "path" import * as path from "path"
import { CookieKeys } from "../../common/http"
import { rootPath } from "../constants" import { rootPath } from "../constants"
import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http" import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http"
import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString, escapeHtml } from "../util" import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString, escapeHtml } from "../util"
export enum Cookie {
Key = "key",
}
// RateLimiter wraps around the limiter library for logins. // RateLimiter wraps around the limiter library for logins.
// It allows 2 logins every minute plus 12 logins every hour. // It allows 2 logins every minute plus 12 logins every hour.
export class RateLimiter { export class RateLimiter {
@ -62,7 +59,7 @@ router.get("/", async (req, res) => {
res.send(await getRoot(req)) res.send(await getRoot(req))
}) })
router.post("/", async (req, res) => { router.post<{}, string, { password: string; base?: string }, { to?: string }>("/", async (req, res) => {
const password = sanitizeString(req.body.password) const password = sanitizeString(req.body.password)
const hashedPasswordFromArgs = req.args["hashed-password"] const hashedPasswordFromArgs = req.args["hashed-password"]
@ -87,13 +84,13 @@ router.post("/", async (req, res) => {
if (isPasswordValid) { 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, hashedPassword, { res.cookie(CookieKeys.Session, hashedPassword, {
domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]), domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]),
// Browsers do not appear to allow cookies to be set relatively so we // Browsers do not appear to allow cookies to be set relatively so we
// need to get the root path from the browser since the proxy rewrites // need to get the root path from the browser since the proxy rewrites
// it out of the path. Otherwise code-server instances hosted on // it out of the path. Otherwise code-server instances hosted on
// separate sub-paths will clobber each other. // separate sub-paths will clobber each other.
path: req.body.base ? path.posix.join(req.body.base, "..") : "/", path: req.body.base ? path.posix.join(req.body.base, "..", "/") : "/",
sameSite: "lax", sameSite: "lax",
}) })

View File

@ -1,17 +1,21 @@
import { Router } from "express" import { Router } from "express"
import { CookieKeys } from "../../common/http"
import { getCookieDomain, redirect } from "../http" import { getCookieDomain, redirect } from "../http"
import { Cookie } from "./login"
import { sanitizeString } from "../util"
export const router = Router() export const router = Router()
router.get("/", async (req, res) => { router.get<{}, undefined, undefined, { base?: string; to?: string }>("/", async (req, res) => {
const path = sanitizeString(req.query.base) || "/"
const to = sanitizeString(req.query.to) || "/"
// Must use the *identical* properties used to set the cookie. // Must use the *identical* properties used to set the cookie.
res.clearCookie(Cookie.Key, { res.clearCookie(CookieKeys.Session, {
domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]), domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]),
path: req.query.base || "/", path: decodeURIComponent(path),
sameSite: "lax", sameSite: "lax",
}) })
const to = (typeof req.query.to === "string" && req.query.to) || "/"
return redirect(req, res, to, { to: undefined, base: undefined }) return redirect(req, res, to, { to: undefined, base: undefined })
}) })

View File

@ -24,7 +24,7 @@ export class CodeServerRouteWrapper {
const isAuthenticated = await authenticated(req) const isAuthenticated = await authenticated(req)
if (!isAuthenticated) { 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,
}) })
@ -88,9 +88,12 @@ export class CodeServerRouteWrapper {
try { try {
this._codeServerMain = await createVSServer(null, { this._codeServerMain = await createVSServer(null, {
connectionToken: "0000", "connection-token": "0000",
"accept-server-license-terms": true,
...args, ...args,
// For some reason VS Code takes the port as a string. /** Type casting. */
help: !!args.help,
version: !!args.version,
port: args.port?.toString(), port: args.port?.toString(),
}) })
} catch (createServerError) { } catch (createServerError) {

View File

@ -321,7 +321,7 @@ export async function isCookieValid({
* - greater than 0 characters * - greater than 0 characters
* - trims whitespace * - trims whitespace
*/ */
export function sanitizeString(str: string): string { export function sanitizeString(str: unknown): string {
// Very basic sanitization of string // Very basic sanitization of string
// Credit: https://stackoverflow.com/a/46719000/3015595 // Credit: https://stackoverflow.com/a/46719000/3015595
return typeof str === "string" && str.trim().length > 0 ? str.trim() : "" return typeof str === "string" && str.trim().length > 0 ? str.trim() : ""

View File

@ -19,7 +19,7 @@ describe("http", () => {
const httpError = new HttpError(message, HttpCode.BadRequest) const httpError = new HttpError(message, HttpCode.BadRequest)
expect(httpError.message).toBe(message) expect(httpError.message).toBe(message)
expect(httpError.status).toBe(400) expect(httpError.statusCode).toBe(400)
expect(httpError.details).toBeUndefined() expect(httpError.details).toBeUndefined()
}) })
it("should have details if provided", () => { it("should have details if provided", () => {

View File

@ -1,4 +1,5 @@
import { Cookie } from "playwright" import { Cookie } from "playwright"
import { CookieKeys } from "../../src/common/http"
import { hash } from "../../src/node/util" import { hash } from "../../src/node/util"
import { PASSWORD, workspaceDir } from "./constants" import { PASSWORD, workspaceDir } from "./constants"
import { clean } from "./helpers" import { clean } from "./helpers"
@ -27,7 +28,7 @@ export default async function () {
domain: "localhost", domain: "localhost",
expires: -1, expires: -1,
httpOnly: false, httpOnly: false,
name: "key", name: CookieKeys.Session,
path: "/", path: "/",
sameSite: "Lax", sameSite: "Lax",
secure: false, secure: false,

2
vendor/package.json vendored
View File

@ -7,6 +7,6 @@
"postinstall": "./postinstall.sh" "postinstall": "./postinstall.sh"
}, },
"devDependencies": { "devDependencies": {
"code-oss-dev": "cdr/vscode#a1d3f915454bb88e508c269a3c5bafb3cfa6f9f6" "code-oss-dev": "cdr/vscode#5e0c6f3b95ed032e62c49101ae502a46c62ef202"
} }
} }

4
vendor/yarn.lock vendored
View File

@ -296,9 +296,9 @@ clone-response@^1.0.2:
dependencies: dependencies:
mimic-response "^1.0.0" mimic-response "^1.0.0"
code-oss-dev@cdr/vscode#a1d3f915454bb88e508c269a3c5bafb3cfa6f9f6: code-oss-dev@cdr/vscode#5e0c6f3b95ed032e62c49101ae502a46c62ef202:
version "1.61.1" version "1.61.1"
resolved "https://codeload.github.com/cdr/vscode/tar.gz/a1d3f915454bb88e508c269a3c5bafb3cfa6f9f6" resolved "https://codeload.github.com/cdr/vscode/tar.gz/5e0c6f3b95ed032e62c49101ae502a46c62ef202"
dependencies: dependencies:
"@microsoft/applicationinsights-web" "^2.6.4" "@microsoft/applicationinsights-web" "^2.6.4"
"@vscode/sqlite3" "4.0.12" "@vscode/sqlite3" "4.0.12"