Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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‎

Lines changed: 17 additions & 8 deletions
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+
exportclass 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‎

Lines changed: 2 additions & 2 deletions
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‎

Lines changed: 48 additions & 0 deletions
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‎

Lines changed: 0 additions & 18 deletions
This file was deleted.

‎test/unit/routes/login.test.ts‎

Lines changed: 37 additions & 0 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /