mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
added rate limiting for attempts for account creation
This commit is contained in:
parent
cfd3f4f886
commit
5bf837cc09
4 changed files with 189 additions and 2 deletions
|
|
@ -100,6 +100,13 @@ function createSchema(database: DatabaseSync) {
|
|||
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_unit ON notes(unitId);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(createdAt);
|
||||
|
|
|
|||
61
src/lib/server/verificationRateLimit.spec.ts
Normal file
61
src/lib/server/verificationRateLimit.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
93
src/lib/server/verificationRateLimit.ts
Normal file
93
src/lib/server/verificationRateLimit.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,17 @@
|
|||
import { json } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
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 args: Record<string, unknown>;
|
||||
|
||||
|
|
@ -15,11 +24,28 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
}
|
||||
|
||||
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>);
|
||||
return json({ ok: true, result });
|
||||
} catch (err: any) {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue