Merge pull request #8 from dsec-hub/feature/rate-limiting

added rate limiting for attempts for account creation
This commit is contained in:
RythonDev 2026-09-02 19:32:54 +08:00 committed by GitHub
commit 3dfea29ee7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 189 additions and 2 deletions

View file

@ -100,6 +100,13 @@ function createSchema(database: DatabaseSync) {
createdAt INTEGER NOT NULL createdAt INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS verification_request_limits (
identifier TEXT PRIMARY KEY,
requestCount INTEGER NOT NULL DEFAULT 0,
windowStartedAt INTEGER NOT NULL,
blockedUntil INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(topicId); CREATE INDEX IF NOT EXISTS idx_notes_topic ON notes(topicId);
CREATE INDEX IF NOT EXISTS idx_notes_unit ON notes(unitId); CREATE INDEX IF NOT EXISTS idx_notes_unit ON notes(unitId);
CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt); CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt);

View file

@ -0,0 +1,61 @@
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
VERIFICATION_BAN_MS,
VerificationRateLimitError,
recordVerificationRequest,
} from "./verificationRateLimit";
describe("verification request rate limit", () => {
let db: DatabaseSync;
beforeEach(() => {
db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE verification_request_limits (
identifier TEXT PRIMARY KEY,
requestCount INTEGER NOT NULL DEFAULT 0,
windowStartedAt INTEGER NOT NULL,
blockedUntil INTEGER NOT NULL DEFAULT 0
)
`);
});
afterEach(() => {
db.close();
});
it("blocks the IP and browser on the fifth request", () => {
const now = 1_000;
for (let attempt = 0; attempt < 4; attempt++) {
expect(() =>
recordVerificationRequest(db, "192.0.2.1", "browser-a", now),
).not.toThrow();
}
expect(() => recordVerificationRequest(db, "192.0.2.1", "browser-a", now)).toThrow(
VerificationRateLimitError,
);
expect(() => recordVerificationRequest(db, "192.0.2.1", "browser-b", now)).toThrow(
VerificationRateLimitError,
);
expect(() => recordVerificationRequest(db, "198.51.100.1", "browser-a", now)).toThrow(
VerificationRateLimitError,
);
});
it("allows requests again after one day", () => {
const now = 1_000;
for (let attempt = 0; attempt < 5; attempt++) {
try {
recordVerificationRequest(db, "192.0.2.1", "browser-a", now);
} catch (error) {
expect(error).toBeInstanceOf(VerificationRateLimitError);
}
}
expect(() =>
recordVerificationRequest(db, "192.0.2.1", "browser-a", now + VERIFICATION_BAN_MS),
).not.toThrow();
});
});

View file

@ -0,0 +1,93 @@
import { createHash } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
export const VERIFICATION_REQUEST_LIMIT = 5;
export const VERIFICATION_BAN_MS = 24 * 60 * 60 * 1000;
type LimitRow = {
requestCount: number;
windowStartedAt: number;
blockedUntil: number;
};
export class VerificationRateLimitError extends Error {
constructor(public readonly blockedUntil: number) {
super("Too many verification requests. Try again in 24 hours.");
this.name = "VerificationRateLimitError";
}
}
function identifier(type: "ip" | "client", value: string): string {
return createHash("sha256").update(`${type}:${value}`).digest("hex");
}
/**
* Records a verification-email request for both the IP and browser. The fifth
* request starts a one-day block and is rejected.
*/
export function recordVerificationRequest(
db: DatabaseSync,
ip: string,
clientId: string,
now = Date.now(),
): void {
const identifiers = [identifier("ip", ip), identifier("client", clientId)];
const select = db.prepare(
"SELECT requestCount, windowStartedAt, blockedUntil FROM verification_request_limits WHERE identifier = ?",
);
const upsert = db.prepare(`
INSERT INTO verification_request_limits
(identifier, requestCount, windowStartedAt, blockedUntil)
VALUES (?, ?, ?, ?)
ON CONFLICT(identifier) DO UPDATE SET
requestCount = excluded.requestCount,
windowStartedAt = excluded.windowStartedAt,
blockedUntil = excluded.blockedUntil
`);
db.exec("BEGIN IMMEDIATE");
try {
const rows = identifiers.map((key) => select.get(key) as LimitRow | undefined);
const activeBlock = Math.max(
0,
...rows.map((row) => (row && row.blockedUntil > now ? row.blockedUntil : 0)),
);
if (activeBlock > now) {
for (let index = 0; index < identifiers.length; index++) {
const row = rows[index];
upsert.run(
identifiers[index],
Math.max(row?.requestCount ?? 0, VERIFICATION_REQUEST_LIMIT),
row?.windowStartedAt ?? now,
activeBlock,
);
}
db.exec("COMMIT");
throw new VerificationRateLimitError(activeBlock);
}
const counts = rows.map((row) =>
row && now - row.windowStartedAt < VERIFICATION_BAN_MS ? row.requestCount + 1 : 1,
);
const shouldBlock = counts.some((count) => count >= VERIFICATION_REQUEST_LIMIT);
const blockedUntil = shouldBlock ? now + VERIFICATION_BAN_MS : 0;
for (let index = 0; index < identifiers.length; index++) {
const row = rows[index];
const windowStartedAt =
row && now - row.windowStartedAt < VERIFICATION_BAN_MS ? row.windowStartedAt : now;
upsert.run(identifiers[index], counts[index], windowStartedAt, blockedUntil);
}
db.exec("COMMIT");
if (shouldBlock) {
throw new VerificationRateLimitError(blockedUntil);
}
} catch (error) {
if (!(error instanceof VerificationRateLimitError)) {
db.exec("ROLLBACK");
}
throw error;
}
}

View file

@ -1,8 +1,17 @@
import { json } from "@sveltejs/kit"; import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
import { call } from "$lib/server/api"; import { call } from "$lib/server/api";
import { getDb } from "$lib/server/db";
import {
VERIFICATION_BAN_MS,
VerificationRateLimitError,
recordVerificationRequest,
} from "$lib/server/verificationRateLimit";
export const POST: RequestHandler = async ({ request }) => { const VERIFICATION_REQUEST_FUNCTIONS = new Set(["auth:requestCode", "admin:requestCode"]);
const VERIFICATION_CLIENT_COOKIE = "dsec_verification_client";
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
let fn: string; let fn: string;
let args: Record<string, unknown>; let args: Record<string, unknown>;
@ -15,11 +24,28 @@ export const POST: RequestHandler = async ({ request }) => {
} }
try { try {
if (VERIFICATION_REQUEST_FUNCTIONS.has(fn)) {
const clientId = cookies.get(VERIFICATION_CLIENT_COOKIE) ?? crypto.randomUUID();
cookies.set(VERIFICATION_CLIENT_COOKIE, clientId, {
path: "/",
httpOnly: true,
maxAge: VERIFICATION_BAN_MS / 1000,
sameSite: "lax",
secure: new URL(request.url).protocol === "https:",
});
recordVerificationRequest(getDb(), getClientAddress(), clientId);
}
const result = await call(fn, args as Record<string, any>); const result = await call(fn, args as Record<string, any>);
return json({ ok: true, result }); return json({ ok: true, result });
} catch (err: any) { } catch (err: any) {
const message = err?.message ?? "Internal error"; const message = err?.message ?? "Internal error";
const status = message === "Not authenticated" || message === "Not authorized" ? 401 : 400; const status =
err instanceof VerificationRateLimitError
? 429
: message === "Not authenticated" || message === "Not authorized"
? 401
: 400;
return json({ ok: false, error: message }, { status }); return json({ ok: false, error: message }, { status });
} }
}; };