-
Notifications
You must be signed in to change notification settings - Fork 189
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Simple protection against brute force attacks (#1152)
- Loading branch information
Showing
4 changed files
with
175 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
import { assert, assertEquals } from "@std/assert"; | ||
import { LockoutTimer } from "./lockout.ts"; | ||
import { FakeTime } from "@std/testing/time"; | ||
|
||
const lockoutTime = 60000; | ||
const lockoutLimit = 10; | ||
|
||
Deno.test("Lockout - failed login rate limiter", async () => { | ||
const envLockoutTime = Deno.env.get("SB_LOCKOUT_TIME_MS") || ""; | ||
const envLockoutLimit = Deno.env.get("SB_LOCKOUT_LIMIT") || ""; | ||
|
||
using time = new FakeTime(); | ||
|
||
testDisabled(new LockoutTimer(NaN, lockoutLimit), "by period"); | ||
testDisabled(new LockoutTimer(lockoutTime, NaN), "by limit"); | ||
|
||
Deno.env.set("SB_LOCKOUT_TIME_MS", String(lockoutTime)); | ||
Deno.env.set("SB_LOCKOUT_LIMIT", ""); | ||
testDisabled(new LockoutTimer(lockoutTime, NaN), "by env period"); | ||
|
||
Deno.env.set("SB_LOCKOUT_TIME_MS", ""); | ||
Deno.env.set("SB_LOCKOUT_LIMIT", String(lockoutLimit)); | ||
testDisabled(new LockoutTimer(lockoutTime, NaN), "by env limit"); | ||
|
||
Deno.env.set("SB_LOCKOUT_TIME_MS", ""); | ||
Deno.env.set("SB_LOCKOUT_LIMIT", ""); | ||
await testLockout(new LockoutTimer(lockoutTime, 10), "explicit params"); | ||
await testLockoutPerMS(new LockoutTimer(lockoutTime, 10), "explicit params"); | ||
|
||
Deno.env.set("SB_LOCKOUT_TIME_MS", String(lockoutTime)); | ||
Deno.env.set("SB_LOCKOUT_LIMIT", String(lockoutLimit)); | ||
await testLockout(new LockoutTimer(), "params from env"); | ||
await testLockoutPerMS(new LockoutTimer(), "params from env"); | ||
|
||
Deno.env.set("SB_LOCKOUT_TIME_MS", envLockoutTime); | ||
Deno.env.set("SB_LOCKOUT_LIMIT", envLockoutLimit); | ||
|
||
function testDisabled(timer: LockoutTimer, txt: string) { | ||
for (let i = 0; i < 100; i++) { | ||
assertEquals( | ||
timer.isLocked(), | ||
false, | ||
`Should be unlocked - disabled ${txt} loop ${i}`, | ||
); | ||
timer.addCount(); | ||
} | ||
} | ||
|
||
function testLockoutPerMS(timer: LockoutTimer, txt: string) { | ||
assert( | ||
lockoutTime > lockoutLimit, | ||
`testLockoutPerMS assumes lockoutTime(${lockoutTime}) > lockoutLimit(${lockoutLimit}), so it can fill a bucket in 1ms jumps`, | ||
); | ||
|
||
const bucketPasses = 2; | ||
|
||
let countLocked = 0; | ||
let countUnLocked = 0; | ||
|
||
const totalTests = lockoutTime * bucketPasses; | ||
|
||
for (let i = 0; i < totalTests; i++) { | ||
if (timer.isLocked()) { | ||
countLocked++; | ||
} else { | ||
countUnLocked++; | ||
} | ||
timer.addCount(); | ||
time.tick(1); | ||
} | ||
|
||
// usually this will be bucketPasses+1 buckets | ||
// but if time aligns could be just bucketPasses buckets | ||
// and could be in between if it doesn't have time to completely fill the extra bucket | ||
|
||
const expectedUnlocked = lockoutLimit * (bucketPasses + 1); | ||
const expectedLocked = totalTests - expectedUnlocked; | ||
|
||
const expectedMinUnlocked = lockoutLimit * bucketPasses; | ||
const expectedMaxLocked = totalTests - expectedMinUnlocked; | ||
|
||
assert( | ||
countUnLocked >= expectedMinUnlocked && countUnLocked <= expectedUnlocked, | ||
`Expected between ${expectedMinUnlocked} and ${expectedUnlocked} unlocks in ${bucketPasses} passes of ${lockoutTime}ms, but got ${countUnLocked} (${txt})`, | ||
); | ||
|
||
assert( | ||
countLocked >= expectedLocked && countLocked <= expectedMaxLocked, | ||
`Expected between ${expectedLocked} and ${expectedMaxLocked} locks in ${bucketPasses} passes of ${lockoutTime}ms, but got ${countLocked} (${txt})`, | ||
); | ||
} | ||
|
||
function testLockout(timer: LockoutTimer, txt: string) { | ||
for (let pass = 0; pass < 3; pass++) { | ||
for (let i = 0; i < lockoutLimit; i++) { | ||
assertEquals( | ||
timer.isLocked(), | ||
false, | ||
`Should be unlocked pass ${pass} loop ${i} ${txt}`, | ||
); | ||
timer.addCount(); | ||
} | ||
// count = lockoutLimit | ||
|
||
for (let i = 0; i < 10; i++) { | ||
assertEquals( | ||
timer.isLocked(), | ||
true, | ||
`Should be locked before bucket rollover. pass ${pass} loop ${i} ${txt}`, | ||
); | ||
timer.addCount(); | ||
} | ||
// count = lockoutLimit + 10 | ||
|
||
time.tick(lockoutTime); | ||
// bucket should rollover, count=0 | ||
} | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
export class LockoutTimer { | ||
// A very simple, quick, inexact lockout timer | ||
// For each request isLocked() or updateBucketTime() must be called before addCount() | ||
// counts in buckets of countPeriodMs | ||
// each period starts with an empty bucket | ||
// suggest SB_LOCKOUT_TIME_MS=60000 SB_LOCKOUT_LIMIT=10 | ||
bucketTime: number = 0; | ||
bucketCount: number = 0; | ||
bucketSize: number; | ||
limit: number; | ||
disabled: boolean; | ||
|
||
constructor( | ||
countPeriodMs: number = Number(Deno.env.get("SB_LOCKOUT_TIME_MS")) || NaN, | ||
limit: number = Number(Deno.env.get("SB_LOCKOUT_LIMIT")) || NaN, | ||
) { | ||
this.disabled = isNaN(countPeriodMs) || isNaN(limit) || countPeriodMs < 1 || | ||
limit < 1; | ||
this.bucketSize = countPeriodMs; | ||
this.limit = limit; | ||
} | ||
|
||
updateBucketTime(): void { | ||
const currentBucketTime = Math.floor(Date.now() / this.bucketSize); | ||
if (this.bucketTime === currentBucketTime) { | ||
return; | ||
} | ||
// the bucket is too old - empty it | ||
this.bucketTime = currentBucketTime; | ||
this.bucketCount = 0; | ||
} | ||
|
||
isLocked(): boolean { | ||
if (this.disabled) { | ||
return false; | ||
} | ||
this.updateBucketTime(); | ||
return this.bucketCount >= this.limit; | ||
} | ||
|
||
addCount(): void { | ||
// isLocked or updateBucketTime must be called first to keep bucketTime current | ||
this.bucketCount++; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters