Skip to content

Commit 4b4ec37

Browse files
authored
Fix relative paths (#4594)
* Add tests for relativeRoot * Remove path.posix.join Since this is for file system paths it feels incorrect to use it on URL paths as they are different in many ways. * Rewrite cookie path logic Before we relied on the client to resolve the base given to it by the backend against the path. Instead have the client pass that information along so we can resolve it on the backend. This means the client has to do less work. * Do not remove out directory before watch This is re-used for incremental compilation. Also remove del since that was the only use (and we can use fs.rmdir in the future if we need something like this). * Remove unused function resolveBase
1 parent 9d9f3a4 commit 4b4ec37

File tree

14 files changed

+91
-153
lines changed

14 files changed

+91
-153
lines changed

ci/dev/watch.ts

-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { spawn, fork, ChildProcess } from "child_process"
2-
import del from "del"
32
import { promises as fs } from "fs"
43
import * as path from "path"
54
import { CompilationStats, onLine, OnLineCallback } from "../../src/node/util"
@@ -57,8 +56,6 @@ class Watcher {
5756
process.on(event, () => this.dispose(0))
5857
}
5958

60-
this.cleanFiles()
61-
6259
for (const [processName, devProcess] of Object.entries(this.compilers)) {
6360
if (!devProcess) continue
6461

@@ -121,15 +118,6 @@ class Watcher {
121118

122119
//#region Utilities
123120

124-
/**
125-
* Cleans files from previous builds.
126-
*/
127-
private cleanFiles(): Promise<string[]> {
128-
console.log("[Watcher]", "Cleaning files from previous builds...")
129-
130-
return del(["out/**/*"])
131-
}
132-
133121
/**
134122
* Emits a file containing compilation data.
135123
* This is especially useful when Express needs to determine if VS Code is still compiling.

package.json

-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
"@typescript-eslint/parser": "^5.0.0",
5353
"audit-ci": "^5.0.0",
5454
"codecov": "^3.8.3",
55-
"del": "^6.0.0",
5655
"doctoc": "^2.0.0",
5756
"eslint": "^7.7.0",
5857
"eslint-config-prettier": "^8.1.0",

src/browser/pages/login.html

+4-3
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ <h1 class="main">Welcome to code-server</h1>
3030
<div class="content">
3131
<form class="login-form" method="post">
3232
<input class="user" type="text" autocomplete="username" />
33-
<input id="base" type="hidden" name="base" value="/" />
33+
<input id="base" type="hidden" name="base" value="{{BASE}}" />
34+
<input id="href" type="hidden" name="href" value="" />
3435
<div class="field">
3536
<input
3637
required
@@ -51,9 +52,9 @@ <h1 class="main">Welcome to code-server</h1>
5152
<script>
5253
// Inform the backend about the path since the proxy might have rewritten
5354
// it out of the headers and cookies must be set with absolute paths.
54-
const el = document.getElementById("base")
55+
const el = document.getElementById("href")
5556
if (el) {
56-
el.value = window.location.pathname
57+
el.value = location.href
5758
}
5859
</script>
5960
</body>

src/common/util.ts

+6-15
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export const generateUuid = (length = 24): string => {
2323

2424
/**
2525
* Remove extra slashes in a URL.
26+
*
27+
* This is meant to fill the job of `path.join` so you can concatenate paths and
28+
* then normalize out any extra slashes.
29+
*
30+
* If you are using `path.join` you do not need this but note that `path` is for
31+
* file system paths, not URLs.
2632
*/
2733
export const normalize = (url: string, keepTrailing = false): string => {
2834
return url.replace(/\/\/+/g, "/").replace(/\/+$/, keepTrailing ? "/" : "")
@@ -35,21 +41,6 @@ export const trimSlashes = (url: string): string => {
3541
return url.replace(/^\/+|\/+$/g, "")
3642
}
3743

38-
/**
39-
* Resolve a relative base against the window location. This is used for
40-
* anything that doesn't work with a relative path.
41-
*/
42-
export const resolveBase = (base?: string): string => {
43-
// After resolving the base will either start with / or be an empty string.
44-
if (!base || base.startsWith("/")) {
45-
return base ?? ""
46-
}
47-
const parts = location.pathname.split("/")
48-
parts[parts.length - 1] = base
49-
const url = new URL(location.origin + "/" + parts.join("/"))
50-
return normalize(url.pathname)
51-
}
52-
5344
/**
5445
* Wrap the value in an array if it's not already an array. If the value is
5546
* undefined return an empty array.

src/node/http.ts

+50-7
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import * as express from "express"
33
import * as expressCore from "express-serve-static-core"
44
import * as http from "http"
55
import * as net from "net"
6-
import path from "path"
76
import * as qs from "qs"
87
import { Disposable } from "../common/emitter"
98
import { CookieKeys, HttpCode, HttpError } from "../common/http"
@@ -18,7 +17,9 @@ import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString, es
1817
*/
1918
export interface ClientConfiguration {
2019
codeServerVersion: string
20+
/** Relative path from this page to the root. No trailing slash. */
2121
base: string
22+
/** Relative path from this page to the static root. No trailing slash. */
2223
csStaticBase: string
2324
}
2425

@@ -33,11 +34,11 @@ declare global {
3334
}
3435

3536
export const createClientConfiguration = (req: express.Request): ClientConfiguration => {
36-
const base = relativeRoot(req)
37+
const base = relativeRoot(req.originalUrl)
3738

3839
return {
3940
base,
40-
csStaticBase: normalize(path.posix.join(base, "_static/")),
41+
csStaticBase: base + "/_static",
4142
codeServerVersion,
4243
}
4344
}
@@ -108,15 +109,28 @@ export const authenticated = async (req: express.Request): Promise<boolean> => {
108109

109110
/**
110111
* Get the relative path that will get us to the root of the page. For each
111-
* slash we need to go up a directory. For example:
112+
* slash we need to go up a directory. Will not have a trailing slash.
113+
*
114+
* For example:
115+
*
112116
* / => .
113117
* /foo => .
114118
* /foo/ => ./..
115119
* /foo/bar => ./..
116120
* /foo/bar/ => ./../..
121+
*
122+
* All paths must be relative in order to work behind a reverse proxy since we
123+
* we do not know the base path. Anything that needs to be absolute (for
124+
* example cookies) must get the base path from the frontend.
125+
*
126+
* All relative paths must be prefixed with the relative root to ensure they
127+
* work no matter the depth at which they happen to appear.
128+
*
129+
* For Express `req.originalUrl` should be used as they remove the base from the
130+
* standard `url` property making it impossible to get the true depth.
117131
*/
118-
export const relativeRoot = (req: express.Request): string => {
119-
const depth = (req.originalUrl.split("?", 1)[0].match(/\//g) || []).length
132+
export const relativeRoot = (originalUrl: string): string => {
133+
const depth = (originalUrl.split("?", 1)[0].match(/\//g) || []).length
120134
return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : ""))
121135
}
122136

@@ -138,7 +152,7 @@ export const redirect = (
138152
}
139153
})
140154

141-
const relativePath = normalize(`${relativeRoot(req)}/${to}`, true)
155+
const relativePath = normalize(`${relativeRoot(req.originalUrl)}/${to}`, true)
142156
const queryString = qs.stringify(query)
143157
const redirectPath = `${relativePath}${queryString ? `?${queryString}` : ""}`
144158
logger.debug(`redirecting from ${req.originalUrl} to ${redirectPath}`)
@@ -241,3 +255,32 @@ export function disposer(server: http.Server): Disposable["dispose"] {
241255
})
242256
}
243257
}
258+
259+
/**
260+
* Get the options for setting a cookie. The options must be identical for
261+
* setting and unsetting cookies otherwise they are considered separate.
262+
*/
263+
export const getCookieOptions = (req: express.Request): express.CookieOptions => {
264+
// Normally we set paths relatively. However browsers do not appear to allow
265+
// cookies to be set relatively which means we need an absolute path. We
266+
// cannot be guaranteed we know the path since a reverse proxy might have
267+
// rewritten it. That means we need to get the path from the frontend.
268+
269+
// The reason we need to set the path (as opposed to defaulting to /) is to
270+
// avoid code-server instances on different sub-paths clobbering each other or
271+
// from accessing each other's tokens (and to prevent other services from
272+
// accessing code-server's tokens).
273+
274+
// When logging in or out the request must include the href (the full current
275+
// URL of that page) and the relative path to the root as given to it by the
276+
// backend. Using these two we can determine the true absolute root.
277+
const url = new URL(
278+
req.query.base || req.body.base || "/",
279+
req.query.href || req.body.href || "http://" + (req.headers.host || "localhost"),
280+
)
281+
return {
282+
domain: getCookieDomain(url.host, req.args["proxy-domain"]),
283+
path: normalize(url.pathname) || "/",
284+
sameSite: "lax",
285+
}
286+
}

src/node/routes/login.ts

+2-10
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as os from "os"
55
import * as path from "path"
66
import { CookieKeys } from "../../common/http"
77
import { rootPath } from "../constants"
8-
import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http"
8+
import { authenticated, getCookieOptions, redirect, replaceTemplates } from "../http"
99
import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString, escapeHtml } from "../util"
1010

1111
// RateLimiter wraps around the limiter library for logins.
@@ -84,15 +84,7 @@ router.post<{}, string, { password: string; base?: string }, { to?: string }>("/
8484
if (isPasswordValid) {
8585
// The hash does not add any actual security but we do it for
8686
// obfuscation purposes (and as a side effect it handles escaping).
87-
res.cookie(CookieKeys.Session, hashedPassword, {
88-
domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]),
89-
// Browsers do not appear to allow cookies to be set relatively so we
90-
// need to get the root path from the browser since the proxy rewrites
91-
// it out of the path. Otherwise code-server instances hosted on
92-
// separate sub-paths will clobber each other.
93-
path: req.body.base ? path.posix.join(req.body.base, "..", "/") : "/",
94-
sameSite: "lax",
95-
})
87+
res.cookie(CookieKeys.Session, hashedPassword, getCookieOptions(req))
9688

9789
const to = (typeof req.query.to === "string" && req.query.to) || "/"
9890
return redirect(req, res, to, { to: undefined })

src/node/routes/logout.ts

+4-11
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,14 @@
11
import { Router } from "express"
22
import { CookieKeys } from "../../common/http"
3-
import { getCookieDomain, redirect } from "../http"
4-
3+
import { getCookieOptions, redirect } from "../http"
54
import { sanitizeString } from "../util"
65

76
export const router = Router()
87

98
router.get<{}, undefined, undefined, { base?: string; to?: string }>("/", async (req, res) => {
10-
const path = sanitizeString(req.query.base) || "/"
11-
const to = sanitizeString(req.query.to) || "/"
12-
139
// Must use the *identical* properties used to set the cookie.
14-
res.clearCookie(CookieKeys.Session, {
15-
domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]),
16-
path: decodeURIComponent(path),
17-
sameSite: "lax",
18-
})
10+
res.clearCookie(CookieKeys.Session, getCookieOptions(req))
1911

20-
return redirect(req, res, to, { to: undefined, base: undefined })
12+
const to = sanitizeString(req.query.to) || "/"
13+
return redirect(req, res, to, { to: undefined, base: undefined, href: undefined })
2114
})

src/node/routes/vscode.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class CodeServerRouteWrapper {
2424
const isAuthenticated = await authenticated(req)
2525

2626
if (!isAuthenticated) {
27-
return redirect(req, res, "login/", {
27+
return redirect(req, res, "login", {
2828
// req.baseUrl can be blank if already at the root.
2929
to: req.baseUrl && req.baseUrl !== "/" ? req.baseUrl : undefined,
3030
})

src/node/util.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ export async function isCookieValid({
324324
export function sanitizeString(str: unknown): string {
325325
// Very basic sanitization of string
326326
// Credit: https://stackoverflow.com/a/46719000/3015595
327-
return typeof str === "string" && str.trim().length > 0 ? str.trim() : ""
327+
return typeof str === "string" ? str.trim() : ""
328328
}
329329

330330
const mimeTypes: { [key: string]: string } = {

test/unit/common/util.test.ts

-36
Original file line numberDiff line numberDiff line change
@@ -74,42 +74,6 @@ describe("util", () => {
7474
})
7575
})
7676

77-
describe("resolveBase", () => {
78-
beforeEach(() => {
79-
const location: LocationLike = {
80-
pathname: "/healthz",
81-
origin: "http://localhost:8080",
82-
}
83-
84-
// Because resolveBase is not a pure function
85-
// and relies on the global location to be set
86-
// we set it before all the tests
87-
// and tell TS that our location should be looked at
88-
// as Location (even though it's missing some properties)
89-
global.location = location as Location
90-
})
91-
92-
it("should resolve a base", () => {
93-
expect(util.resolveBase("localhost:8080")).toBe("/localhost:8080")
94-
})
95-
96-
it("should resolve a base with a forward slash at the beginning", () => {
97-
expect(util.resolveBase("/localhost:8080")).toBe("/localhost:8080")
98-
})
99-
100-
it("should resolve a base with query params", () => {
101-
expect(util.resolveBase("localhost:8080?folder=hello-world")).toBe("/localhost:8080")
102-
})
103-
104-
it("should resolve a base with a path", () => {
105-
expect(util.resolveBase("localhost:8080/hello/world")).toBe("/localhost:8080/hello/world")
106-
})
107-
108-
it("should resolve a base to an empty string when not provided", () => {
109-
expect(util.resolveBase()).toBe("")
110-
})
111-
})
112-
11377
describe("arrayify", () => {
11478
it("should return value it's already an array", () => {
11579
expect(util.arrayify(["hello", "world"])).toStrictEqual(["hello", "world"])

test/unit/node/http.test.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { relativeRoot } from "../../../src/node/http"
2+
3+
describe("http", () => {
4+
it("should construct a relative path to the root", () => {
5+
expect(relativeRoot("/")).toStrictEqual(".")
6+
expect(relativeRoot("/foo")).toStrictEqual(".")
7+
expect(relativeRoot("/foo/")).toStrictEqual("./..")
8+
expect(relativeRoot("/foo/bar ")).toStrictEqual("./..")
9+
expect(relativeRoot("/foo/bar/")).toStrictEqual("./../..")
10+
})
11+
})

vendor/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
"postinstall": "./postinstall.sh"
88
},
99
"devDependencies": {
10-
"code-oss-dev": "cdr/vscode#c2a251c6afaa13fbebf97fcd8a68192f8cf46031"
10+
"code-oss-dev": "cdr/vscode#478224aa345e9541f2427b30142dd13ee7e14d39"
1111
}
1212
}

vendor/yarn.lock

+2-2
Original file line numberDiff line numberDiff line change
@@ -296,9 +296,9 @@ clone-response@^1.0.2:
296296
dependencies:
297297
mimic-response "^1.0.0"
298298

299-
code-oss-dev@cdr/vscode#c2a251c6afaa13fbebf97fcd8a68192f8cf46031:
299+
code-oss-dev@cdr/vscode#478224aa345e9541f2427b30142dd13ee7e14d39:
300300
version "1.61.1"
301-
resolved "https://codeload.github.com/cdr/vscode/tar.gz/c2a251c6afaa13fbebf97fcd8a68192f8cf46031"
301+
resolved "https://codeload.github.com/cdr/vscode/tar.gz/478224aa345e9541f2427b30142dd13ee7e14d39"
302302
dependencies:
303303
"@microsoft/applicationinsights-web" "^2.6.4"
304304
"@vscode/sqlite3" "4.0.12"

0 commit comments

Comments
 (0)