Skip to content

Commit 975dd13

Browse files
authored
Merge pull request #3695 from cdr/jsjoeio-sanitize-error-msg
fix: escape error.message on login failure
2 parents faa896c + c0e123a commit 975dd13

File tree

5 files changed

+70
-6
lines changed

5 files changed

+70
-6
lines changed

src/node/http.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { normalize, Options } from "../common/util"
77
import { AuthType, DefaultedArgs } from "./cli"
88
import { commit, rootPath } from "./constants"
99
import { Heart } from "./heart"
10-
import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString } from "./util"
10+
import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString, escapeHtml } from "./util"
1111

1212
declare global {
1313
// eslint-disable-next-line @typescript-eslint/no-namespace
@@ -35,7 +35,7 @@ export const replaceTemplates = <T extends object>(
3535
...extraOpts,
3636
}
3737
return content
38-
.replace(/{{TO}}/g, (typeof req.query.to === "string" && req.query.to) || "/")
38+
.replace(/{{TO}}/g, (typeof req.query.to === "string" && escapeHtml(req.query.to)) || "/")
3939
.replace(/{{BASE}}/g, options.base)
4040
.replace(/{{CS_STATIC_BASE}}/g, options.csStaticBase)
4141
.replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`)
@@ -100,7 +100,8 @@ export const relativeRoot = (req: express.Request): string => {
100100
}
101101

102102
/**
103-
* Redirect relatively to `/${to}`. Query variables will be preserved.
103+
* Redirect relatively to `/${to}`. Query variables on the current URI will be preserved.
104+
* `to` should be a simple path without any query parameters
104105
* `override` will merge with the existing query (use `undefined` to unset).
105106
*/
106107
export const redirect = (

src/node/routes/login.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { RateLimiter as Limiter } from "limiter"
44
import * as path from "path"
55
import { rootPath } from "../constants"
66
import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http"
7-
import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString } from "../util"
7+
import { getPasswordMethod, handlePasswordValidation, humanPath, sanitizeString, escapeHtml } from "../util"
88

99
export enum Cookie {
1010
Key = "key",
@@ -36,11 +36,12 @@ const getRoot = async (req: Request, error?: Error): Promise<string> => {
3636
} else if (req.args.usingEnvHashedPassword) {
3737
passwordMsg = "Password was set from $HASHED_PASSWORD."
3838
}
39+
3940
return replaceTemplates(
4041
req,
4142
content
4243
.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
43-
.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : ""),
44+
.replace(/{{ERROR}}/, error ? `<div class="error">${escapeHtml(error.message)}</div>` : ""),
4445
)
4546
}
4647

@@ -111,6 +112,7 @@ router.post("/", async (req, res) => {
111112

112113
throw new Error("Incorrect password")
113114
} catch (error) {
114-
res.send(await getRoot(req, error))
115+
const renderedHtml = await getRoot(req, error)
116+
res.send(renderedHtml)
115117
}
116118
})

src/node/util.ts

+14
Original file line numberDiff line numberDiff line change
@@ -508,3 +508,17 @@ export const isFile = async (path: string): Promise<boolean> => {
508508
return false
509509
}
510510
}
511+
512+
/**
513+
* Escapes any HTML string special characters, like &, <, >, ", and '.
514+
*
515+
* Source: https://stackoverflow.com/a/6234804/3015595
516+
**/
517+
export function escapeHtml(unsafe: string): string {
518+
return unsafe
519+
.replace(/&/g, "&amp;")
520+
.replace(/</g, "&lt;")
521+
.replace(/>/g, "&gt;")
522+
.replace(/"/g, "&quot;")
523+
.replace(/'/g, "&apos;")
524+
}

test/unit/node/util.test.ts

+8
Original file line numberDiff line numberDiff line change
@@ -445,3 +445,11 @@ describe("onLine", () => {
445445
expect(await received).toEqual(expected)
446446
})
447447
})
448+
449+
describe("escapeHtml", () => {
450+
it("should escape HTML", () => {
451+
expect(util.escapeHtml(`<div class="error">"'ello & world"</div>`)).toBe(
452+
"&lt;div class=&quot;error&quot;&gt;&quot;&apos;ello &amp; world&quot;&lt;/div&gt;",
453+
)
454+
})
455+
})

test/unit/routes/login.test.ts

+39
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { RateLimiter } from "../../../src/node/routes/login"
2+
import * as httpserver from "../../utils/httpserver"
3+
import * as integration from "../../utils/integration"
24

35
describe("login", () => {
46
describe("RateLimiter", () => {
@@ -34,4 +36,41 @@ describe("login", () => {
3436
expect(limiter.removeToken()).toBe(false)
3537
})
3638
})
39+
describe("/login", () => {
40+
let _codeServer: httpserver.HttpServer | undefined
41+
function codeServer(): httpserver.HttpServer {
42+
if (!_codeServer) {
43+
throw new Error("tried to use code-server before setting it up")
44+
}
45+
return _codeServer
46+
}
47+
48+
// Store whatever might be in here so we can restore it afterward.
49+
// TODO: We should probably pass this as an argument somehow instead of
50+
// manipulating the environment.
51+
const previousEnvPassword = process.env.PASSWORD
52+
53+
beforeEach(async () => {
54+
process.env.PASSWORD = "test"
55+
_codeServer = await integration.setup(["--auth=password"], "")
56+
})
57+
58+
afterEach(async () => {
59+
process.env.PASSWORD = previousEnvPassword
60+
if (_codeServer) {
61+
await _codeServer.close()
62+
_codeServer = undefined
63+
}
64+
})
65+
66+
it("should return HTML with 'Missing password' message", async () => {
67+
const resp = await codeServer().fetch("/login", { method: "POST" })
68+
69+
expect(resp.status).toBe(200)
70+
71+
const htmlContent = await resp.text()
72+
73+
expect(htmlContent).toContain("Missing password")
74+
})
75+
})
3776
})

0 commit comments

Comments
 (0)