mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 15:43:58 +00:00
Merge pull request #8 from dsec-hub/feature/rate-limiting
added rate limiting for attempts for account creation
This commit is contained in:
commit
3dfea29ee7
4 changed files with 189 additions and 2 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
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 { 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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue