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:
parent
6a2740f57e
commit
62b3a6fd9f
@ -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",
|
||||||
|
}
|
||||||
|
@ -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"],
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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 })
|
||||||
})
|
})
|
||||||
|
@ -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) {
|
||||||
|
@ -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() : ""
|
||||||
|
@ -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", () => {
|
||||||
|
@ -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
2
vendor/package.json
vendored
@ -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
4
vendor/yarn.lock
vendored
@ -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"
|
||||||
|
Reference in New Issue
Block a user