Skip to content

Commit 6d65680

Browse files
Merge pull request #3141 from cdr/jsjoeio/fix-login-rate-limiter
fix(login): rate limiter shouldn't count successful logins
2 parents f21884c + f80d5c3 commit 6d65680

File tree

5 files changed

+104
-28
lines changed

5 files changed

+104
-28
lines changed

src/node/routes/login.ts

+17-8
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,20 @@ export enum Cookie {
1212
}
1313

1414
// RateLimiter wraps around the limiter library for logins.
15-
// It allows 2 logins every minute and 12 logins every hour.
16-
class RateLimiter {
15+
// It allows 2 logins every minute plus 12 logins every hour.
16+
export class RateLimiter {
1717
private readonly minuteLimiter = new Limiter(2, "minute")
1818
private readonly hourLimiter = new Limiter(12, "hour")
1919

20-
public try(): boolean {
21-
if (this.minuteLimiter.tryRemoveTokens(1)) {
22-
return true
23-
}
24-
return this.hourLimiter.tryRemoveTokens(1)
20+
public canTry(): boolean {
21+
// Note: we must check using >= 1 because technically when there are no tokens left
22+
// you get back a number like 0.00013333333333333334
23+
// which would cause fail if the logic were > 0
24+
return this.minuteLimiter.getTokensRemaining() >= 1 || this.hourLimiter.getTokensRemaining() >= 1
25+
}
26+
27+
public removeToken(): boolean {
28+
return this.minuteLimiter.tryRemoveTokens(1) || this.hourLimiter.tryRemoveTokens(1)
2529
}
2630
}
2731

@@ -59,7 +63,8 @@ router.get("/", async (req, res) => {
5963

6064
router.post("/", async (req, res) => {
6165
try {
62-
if (!limiter.try()) {
66+
// Check to see if they exceeded their login attempts
67+
if (!limiter.canTry()) {
6368
throw new Error("Login rate limited!")
6469
}
6570

@@ -84,6 +89,10 @@ router.post("/", async (req, res) => {
8489
return redirect(req, res, to, { to: undefined })
8590
}
8691

92+
// Note: successful logins should not count against the RateLimiter
93+
// which is why this logic must come after the successful login logic
94+
limiter.removeToken()
95+
8796
console.error(
8897
"Failed login attempt",
8998
JSON.stringify({

test/config.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ globalSetup(async () => {
5050

5151
const config: Config = {
5252
testDir: path.join(__dirname, "e2e"), // Search for tests in this directory.
53-
timeout: 30000, // Each test is given 30 seconds.
53+
timeout: 60000, // Each test is given 60 seconds.
5454
retries: 3, // Retry failing tests 2 times
5555
}
5656

@@ -64,7 +64,7 @@ setConfig(config)
6464

6565
const options: PlaywrightOptions = {
6666
headless: true, // Run tests in headless browsers.
67-
video: "retain-on-failure",
67+
video: "on",
6868
}
6969

7070
// Run tests in three browsers.

test/e2e/login.test.ts

+48
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ test.describe("login", () => {
1010
},
1111
}
1212

13+
test("should see the login page", options, async ({ page }) => {
14+
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
15+
// It should send us to the login page
16+
expect(await page.title()).toBe("code-server login")
17+
})
18+
1319
test("should be able to login", options, async ({ page }) => {
1420
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
1521
// Type in password
@@ -20,4 +26,46 @@ test.describe("login", () => {
2026
// Make sure the editor actually loaded
2127
expect(await page.isVisible("div.monaco-workbench"))
2228
})
29+
30+
test("should see an error message for missing password", options, async ({ page }) => {
31+
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
32+
// Skip entering password
33+
// Click the submit button and login
34+
await page.click(".submit")
35+
await page.waitForLoadState("networkidle")
36+
expect(await page.isVisible("text=Missing password"))
37+
})
38+
39+
test("should see an error message for incorrect password", options, async ({ page }) => {
40+
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
41+
// Type in password
42+
await page.fill(".password", "password123")
43+
// Click the submit button and login
44+
await page.click(".submit")
45+
await page.waitForLoadState("networkidle")
46+
expect(await page.isVisible("text=Incorrect password"))
47+
})
48+
49+
test("should hit the rate limiter for too many unsuccessful logins", options, async ({ page }) => {
50+
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
51+
// Type in password
52+
await page.fill(".password", "password123")
53+
// Click the submit button and login
54+
// The current RateLimiter allows 2 logins per minute plus
55+
// 12 logins per hour for a total of 14
56+
// See: src/node/routes/login.ts
57+
for (let i = 1; i <= 14; i++) {
58+
await page.click(".submit")
59+
await page.waitForLoadState("networkidle")
60+
// We double-check that the correct error message shows
61+
// which should be for incorrect password
62+
expect(await page.isVisible("text=Incorrect password"))
63+
}
64+
65+
// The 15th should fail for a different reason:
66+
// login rate
67+
await page.click(".submit")
68+
await page.waitForLoadState("networkidle")
69+
expect(await page.isVisible("text=Login rate limited!"))
70+
})
2371
})

test/e2e/loginPage.test.ts

-18
This file was deleted.

test/unit/routes/login.test.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { RateLimiter } from "../../../src/node/routes/login"
2+
3+
describe("login", () => {
4+
describe("RateLimiter", () => {
5+
it("should allow one try ", () => {
6+
const limiter = new RateLimiter()
7+
expect(limiter.removeToken()).toBe(true)
8+
})
9+
10+
it("should pull tokens from both limiters (minute & hour)", () => {
11+
const limiter = new RateLimiter()
12+
13+
// Try twice, which pulls two from the minute bucket
14+
limiter.removeToken()
15+
limiter.removeToken()
16+
17+
// Check that we can still try
18+
// which should be true since there are 12 remaining in the hour bucket
19+
expect(limiter.canTry()).toBe(true)
20+
expect(limiter.removeToken()).toBe(true)
21+
})
22+
23+
it("should not allow more than 14 tries in less than an hour", () => {
24+
const limiter = new RateLimiter()
25+
26+
// The limiter allows 2 tries per minute plus 12 per hour
27+
// so if we run it 15 times, 14 should return true and the last
28+
// should return false
29+
for (let i = 1; i <= 14; i++) {
30+
expect(limiter.removeToken()).toBe(true)
31+
}
32+
33+
expect(limiter.canTry()).toBe(false)
34+
expect(limiter.removeToken()).toBe(false)
35+
})
36+
})
37+
})

0 commit comments

Comments
 (0)