Merge pull request #3141 from cdr/jsjoeio/fix-login-rate-limiter
fix(login): rate limiter shouldn't count successful logins
This commit is contained in:
commit
6d65680c23
|
@ -12,16 +12,20 @@ export enum Cookie {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RateLimiter wraps around the limiter library for logins.
|
// RateLimiter wraps around the limiter library for logins.
|
||||||
// It allows 2 logins every minute and 12 logins every hour.
|
// It allows 2 logins every minute plus 12 logins every hour.
|
||||||
class RateLimiter {
|
export class RateLimiter {
|
||||||
private readonly minuteLimiter = new Limiter(2, "minute")
|
private readonly minuteLimiter = new Limiter(2, "minute")
|
||||||
private readonly hourLimiter = new Limiter(12, "hour")
|
private readonly hourLimiter = new Limiter(12, "hour")
|
||||||
|
|
||||||
public try(): boolean {
|
public canTry(): boolean {
|
||||||
if (this.minuteLimiter.tryRemoveTokens(1)) {
|
// Note: we must check using >= 1 because technically when there are no tokens left
|
||||||
return true
|
// you get back a number like 0.00013333333333333334
|
||||||
}
|
// which would cause fail if the logic were > 0
|
||||||
return this.hourLimiter.tryRemoveTokens(1)
|
return this.minuteLimiter.getTokensRemaining() >= 1 || this.hourLimiter.getTokensRemaining() >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeToken(): boolean {
|
||||||
|
return this.minuteLimiter.tryRemoveTokens(1) || this.hourLimiter.tryRemoveTokens(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,7 +63,8 @@ router.get("/", async (req, res) => {
|
||||||
|
|
||||||
router.post("/", async (req, res) => {
|
router.post("/", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!limiter.try()) {
|
// Check to see if they exceeded their login attempts
|
||||||
|
if (!limiter.canTry()) {
|
||||||
throw new Error("Login rate limited!")
|
throw new Error("Login rate limited!")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,6 +89,10 @@ router.post("/", async (req, res) => {
|
||||||
return redirect(req, res, to, { to: undefined })
|
return redirect(req, res, to, { to: undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: successful logins should not count against the RateLimiter
|
||||||
|
// which is why this logic must come after the successful login logic
|
||||||
|
limiter.removeToken()
|
||||||
|
|
||||||
console.error(
|
console.error(
|
||||||
"Failed login attempt",
|
"Failed login attempt",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|
|
@ -50,7 +50,7 @@ globalSetup(async () => {
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
testDir: path.join(__dirname, "e2e"), // Search for tests in this directory.
|
testDir: path.join(__dirname, "e2e"), // Search for tests in this directory.
|
||||||
timeout: 30000, // Each test is given 30 seconds.
|
timeout: 60000, // Each test is given 60 seconds.
|
||||||
retries: 3, // Retry failing tests 2 times
|
retries: 3, // Retry failing tests 2 times
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ setConfig(config)
|
||||||
|
|
||||||
const options: PlaywrightOptions = {
|
const options: PlaywrightOptions = {
|
||||||
headless: true, // Run tests in headless browsers.
|
headless: true, // Run tests in headless browsers.
|
||||||
video: "retain-on-failure",
|
video: "on",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run tests in three browsers.
|
// Run tests in three browsers.
|
||||||
|
|
|
@ -10,6 +10,12 @@ test.describe("login", () => {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("should see the login page", options, async ({ page }) => {
|
||||||
|
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
|
||||||
|
// It should send us to the login page
|
||||||
|
expect(await page.title()).toBe("code-server login")
|
||||||
|
})
|
||||||
|
|
||||||
test("should be able to login", options, async ({ page }) => {
|
test("should be able to login", options, async ({ page }) => {
|
||||||
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
|
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
|
||||||
// Type in password
|
// Type in password
|
||||||
|
@ -20,4 +26,46 @@ test.describe("login", () => {
|
||||||
// Make sure the editor actually loaded
|
// Make sure the editor actually loaded
|
||||||
expect(await page.isVisible("div.monaco-workbench"))
|
expect(await page.isVisible("div.monaco-workbench"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should see an error message for missing password", options, async ({ page }) => {
|
||||||
|
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
|
||||||
|
// Skip entering password
|
||||||
|
// Click the submit button and login
|
||||||
|
await page.click(".submit")
|
||||||
|
await page.waitForLoadState("networkidle")
|
||||||
|
expect(await page.isVisible("text=Missing password"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should see an error message for incorrect password", options, async ({ page }) => {
|
||||||
|
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
|
||||||
|
// Type in password
|
||||||
|
await page.fill(".password", "password123")
|
||||||
|
// Click the submit button and login
|
||||||
|
await page.click(".submit")
|
||||||
|
await page.waitForLoadState("networkidle")
|
||||||
|
expect(await page.isVisible("text=Incorrect password"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should hit the rate limiter for too many unsuccessful logins", options, async ({ page }) => {
|
||||||
|
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
|
||||||
|
// Type in password
|
||||||
|
await page.fill(".password", "password123")
|
||||||
|
// Click the submit button and login
|
||||||
|
// The current RateLimiter allows 2 logins per minute plus
|
||||||
|
// 12 logins per hour for a total of 14
|
||||||
|
// See: src/node/routes/login.ts
|
||||||
|
for (let i = 1; i <= 14; i++) {
|
||||||
|
await page.click(".submit")
|
||||||
|
await page.waitForLoadState("networkidle")
|
||||||
|
// We double-check that the correct error message shows
|
||||||
|
// which should be for incorrect password
|
||||||
|
expect(await page.isVisible("text=Incorrect password"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 15th should fail for a different reason:
|
||||||
|
// login rate
|
||||||
|
await page.click(".submit")
|
||||||
|
await page.waitForLoadState("networkidle")
|
||||||
|
expect(await page.isVisible("text=Login rate limited!"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { test, expect } from "@playwright/test"
|
|
||||||
import { CODE_SERVER_ADDRESS } from "../utils/constants"
|
|
||||||
|
|
||||||
test.describe("login page", () => {
|
|
||||||
// Reset the browser so no cookies are persisted
|
|
||||||
// by emptying the storageState
|
|
||||||
const options = {
|
|
||||||
contextOptions: {
|
|
||||||
storageState: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
test("should see the login page", options, async ({ page }) => {
|
|
||||||
await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" })
|
|
||||||
// It should send us to the login page
|
|
||||||
expect(await page.title()).toBe("code-server login")
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { RateLimiter } from "../../../src/node/routes/login"
|
||||||
|
|
||||||
|
describe("login", () => {
|
||||||
|
describe("RateLimiter", () => {
|
||||||
|
it("should allow one try ", () => {
|
||||||
|
const limiter = new RateLimiter()
|
||||||
|
expect(limiter.removeToken()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should pull tokens from both limiters (minute & hour)", () => {
|
||||||
|
const limiter = new RateLimiter()
|
||||||
|
|
||||||
|
// Try twice, which pulls two from the minute bucket
|
||||||
|
limiter.removeToken()
|
||||||
|
limiter.removeToken()
|
||||||
|
|
||||||
|
// Check that we can still try
|
||||||
|
// which should be true since there are 12 remaining in the hour bucket
|
||||||
|
expect(limiter.canTry()).toBe(true)
|
||||||
|
expect(limiter.removeToken()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not allow more than 14 tries in less than an hour", () => {
|
||||||
|
const limiter = new RateLimiter()
|
||||||
|
|
||||||
|
// The limiter allows 2 tries per minute plus 12 per hour
|
||||||
|
// so if we run it 15 times, 14 should return true and the last
|
||||||
|
// should return false
|
||||||
|
for (let i = 1; i <= 14; i++) {
|
||||||
|
expect(limiter.removeToken()).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(limiter.canTry()).toBe(false)
|
||||||
|
expect(limiter.removeToken()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue