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:
repo-ranger[bot] 2021-04-19 20:29:11 +00:00 committed by GitHub
commit 6d65680c23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 104 additions and 28 deletions

View File

@ -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({

View File

@ -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.

View File

@ -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!"))
})
}) })

View File

@ -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")
})
})

View File

@ -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)
})
})
})