mirror of https://github.com/requarks/wiki
parent
1105cf4b5e
commit
14e1efae41
@ -0,0 +1,180 @@
|
||||
import { actorFrom, mayOnPage, unlockedFor } from './pages.ts'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
|
||||
/**
|
||||
* The page being watched, as this requester is allowed to see it.
|
||||
*
|
||||
* Watching a page is a thing done TO a page, so it goes through the same gate as reading one: an
|
||||
* anonymous requester never gets here at all, and a page somebody may not read is answered as though
|
||||
* it were not there. A password is not part of it — the watcher is asking to be told when the page
|
||||
* changes, not to read what it says.
|
||||
*/
|
||||
async function loadWatchablePage(req: FastifyRequest, siteId: string, pageId: string) {
|
||||
const page = await WIKI.models.pages.getPage({
|
||||
siteId,
|
||||
id: pageId,
|
||||
unlocked: (id: string) => unlockedFor(req, id)
|
||||
})
|
||||
if (!page || !mayOnPage(req, 'read:pages', page)) {
|
||||
return null
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
/**
|
||||
* The user doing the watching, or a refusal.
|
||||
*
|
||||
* Watching belongs to an account: it is a list somebody comes back to, and a row has to point at a
|
||||
* person for a notification to ever have a recipient. There is no permission for it beyond being
|
||||
* logged in — anybody who may read a page may ask to hear about it.
|
||||
*/
|
||||
function watcherOf(req: FastifyRequest, reply: FastifyReply): string | null {
|
||||
const actor = actorFrom(req)
|
||||
if (!actor) {
|
||||
reply.unauthorized('Watching a page requires a logged in user.')
|
||||
return null
|
||||
}
|
||||
return actor.id
|
||||
}
|
||||
|
||||
const pageParams = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: { type: 'string', format: 'uuid' },
|
||||
pageId: { type: 'string', format: 'uuid' }
|
||||
},
|
||||
required: ['siteId', 'pageId']
|
||||
}
|
||||
|
||||
/**
|
||||
* Page Watching API Routes
|
||||
*
|
||||
* Who has asked to be told when a page changes. Nothing is sent yet — notifications are not built —
|
||||
* so these keep the list: the bell on a page writes to it, and the inbox reads it back.
|
||||
*/
|
||||
async function routes(app: FastifyInstance) {
|
||||
/**
|
||||
* WATCH A PAGE
|
||||
*/
|
||||
app.put<{ Params: { siteId: string; pageId: string } }>(
|
||||
'/sites/:siteId/pages/:pageId/watch',
|
||||
{
|
||||
/*
|
||||
No route-level `permissions`: this is decided per page, by whether the caller may read it —
|
||||
which comes from a group's rules and not from the group-wide list that hook consults.
|
||||
*/
|
||||
schema: {
|
||||
summary: 'Watch a page',
|
||||
description:
|
||||
'Records that the caller wants to hear about changes to this page. Watching a page already watched changes nothing and still answers 200, so the button can be pressed twice without it meaning anything different.',
|
||||
tags: ['Pages'],
|
||||
params: pageParams,
|
||||
response: {
|
||||
200: {
|
||||
description: 'The page is being watched',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
isWatching: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const userId = watcherOf(req, reply)
|
||||
if (!userId) {
|
||||
return reply
|
||||
}
|
||||
const page = await loadWatchablePage(req, req.params.siteId, req.params.pageId)
|
||||
if (!page) {
|
||||
return reply.notFound('This page does not exist.')
|
||||
}
|
||||
await WIKI.models.pageWatching.watch({
|
||||
siteId: req.params.siteId,
|
||||
pageId: page.id,
|
||||
userId
|
||||
})
|
||||
return { ok: true, isWatching: true }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* UNWATCH A PAGE
|
||||
*/
|
||||
app.delete<{ Params: { siteId: string; pageId: string } }>(
|
||||
'/sites/:siteId/pages/:pageId/watch',
|
||||
{
|
||||
// -> Same as above: readable is the test, and it is per page
|
||||
schema: {
|
||||
summary: 'Stop watching a page',
|
||||
description:
|
||||
'Forgets that the caller wanted to hear about this page. A page that was not being watched answers the same way, since the outcome asked for — no longer watching it — already holds.',
|
||||
tags: ['Pages'],
|
||||
params: pageParams,
|
||||
response: {
|
||||
200: {
|
||||
description: 'The page is no longer being watched',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
isWatching: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const userId = watcherOf(req, reply)
|
||||
if (!userId) {
|
||||
return reply
|
||||
}
|
||||
/*
|
||||
The page is NOT loaded first. Unwatching has to keep working for a page that has since been
|
||||
made unreadable, or the row would be stuck there with nothing in the interface able to remove
|
||||
it — and there is nothing to protect anyway: this only ever deletes the caller's own row.
|
||||
*/
|
||||
await WIKI.models.pageWatching.unwatch({ pageId: req.params.pageId, userId })
|
||||
return { ok: true, isWatching: false }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* LIST WATCHED PAGES
|
||||
*/
|
||||
app.get<{ Params: { siteId: string } }>(
|
||||
'/sites/:siteId/watching',
|
||||
{
|
||||
// -> Everything it returns is the caller's own, so being logged in is the whole of the check
|
||||
schema: {
|
||||
summary: 'List the pages the caller is watching',
|
||||
description:
|
||||
'The watch list of the caller on this site, most recently watched first. Titles and paths come from the pages themselves, so a page that has been renamed or moved is listed where it is now.',
|
||||
tags: ['Pages'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: { type: 'string', format: 'uuid' }
|
||||
},
|
||||
required: ['siteId']
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Watched pages',
|
||||
type: 'array',
|
||||
items: { $ref: 'WatchedPage#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const userId = watcherOf(req, reply)
|
||||
if (!userId) {
|
||||
return reply
|
||||
}
|
||||
return WIKI.models.pageWatching.listForUser(req.params.siteId, userId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default routes
|
||||
@ -0,0 +1,13 @@
|
||||
CREATE TABLE "pageWatching" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"pageId" uuid NOT NULL,
|
||||
"siteId" uuid NOT NULL,
|
||||
"userId" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "pageWatching_user_site_idx" ON "pageWatching" ("userId","siteId");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "pageWatching_page_user_idx" ON "pageWatching" ("pageId","userId");--> statement-breakpoint
|
||||
ALTER TABLE "pageWatching" ADD CONSTRAINT "pageWatching_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "pageWatching" ADD CONSTRAINT "pageWatching_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
|
||||
ALTER TABLE "pageWatching" ADD CONSTRAINT "pageWatching_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,9 @@
|
||||
CREATE TABLE "rateLimits" (
|
||||
"key" varchar(255) PRIMARY KEY,
|
||||
"hits" integer DEFAULT 0 NOT NULL,
|
||||
"windowStartedAt" timestamp DEFAULT now() NOT NULL,
|
||||
"bannedUntil" timestamp,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "rateLimits_updatedAt_idx" ON "rateLimits" ("updatedAt");
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,71 @@
|
||||
import { durationToSeconds } from './common.ts'
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||
import type { RateLimitPolicy } from '../models/rateLimits.ts'
|
||||
|
||||
/**
|
||||
* Defaults for the limit on the authentication endpoints, used until an administrator saves their own
|
||||
* and whenever a stored value is missing or unusable. Ten attempts in five minutes is far more than a
|
||||
* person signing in needs and far less than guessing a password takes.
|
||||
*/
|
||||
const AUTH_DEFAULTS: RateLimitPolicy = {
|
||||
max: 10,
|
||||
windowSeconds: 300,
|
||||
banSeconds: 900
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured policy.
|
||||
*
|
||||
* Every field falls back on its own, so one unusable value leaves the rest of the limit standing
|
||||
* rather than turning it off — which is the failure mode worth avoiding here. The two durations are
|
||||
* stored as an operator wrote them (`5m`, `15m`, `1d`), the way the JWT settings beside them are.
|
||||
*/
|
||||
function authPolicy(): RateLimitPolicy {
|
||||
const security = WIKI.config.security ?? {}
|
||||
const max = Number(security.authRateLimitMax)
|
||||
return {
|
||||
max: Number.isFinite(max) && max > 0 ? Math.floor(max) : AUTH_DEFAULTS.max,
|
||||
windowSeconds: durationToSeconds(security.authRateLimitWindow, AUTH_DEFAULTS.windowSeconds),
|
||||
banSeconds: durationToSeconds(security.authRateLimitBan, AUTH_DEFAULTS.banSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse an attempt at an authentication endpoint once a client has made too many.
|
||||
*
|
||||
* Written as a per-route `onRequest` hook — `{ onRequest: limitAuthAttempts, schema: … }` — so that it
|
||||
* runs before the body is even parsed, and so that the routes it guards say so where they are declared
|
||||
* rather than in a list somewhere else. The endpoints that carry it are the ones where the request
|
||||
* IS the guess: signing in, answering a second factor, changing a password from the login screen,
|
||||
* a passkey ceremony, and unlocking a page.
|
||||
*
|
||||
* One counter per client address, shared by all of them: an attacker working through passwords on two
|
||||
* of these endpoints is one attacker, and splitting the count per endpoint would let them have the
|
||||
* limit twice over. `req.ip` is what the client is identified by, which behind a proxy means the
|
||||
* `trustProxy` security setting has to be on for this to see anything but the proxy.
|
||||
*
|
||||
* Attempts are counted whether or not they succeed. A limit on failures only would leave the endpoint
|
||||
* open to being hammered with valid credentials, and the numbers are set for a person signing in, who
|
||||
* does not come close to them.
|
||||
*/
|
||||
export async function limitAuthAttempts(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
if (WIKI.config.security?.authRateLimitEnabled === false) {
|
||||
return
|
||||
}
|
||||
const verdict = await WIKI.models.rateLimits.consume(`auth:${req.ip}`, authPolicy())
|
||||
if (verdict.allowed) {
|
||||
return
|
||||
}
|
||||
WIKI.models.flags.authDebug(
|
||||
`Rate limit: refused ${req.method} ${req.url} from ${req.ip}, ${verdict.retryAfter}s left of its ban.`
|
||||
)
|
||||
/*
|
||||
429 rather than 403, and with `Retry-After`: this is not a refusal to serve the client, it is the
|
||||
same answer as before with a time on it — which is what a legitimate user locked out by a shared
|
||||
address needs to be told.
|
||||
*/
|
||||
reply.header('Retry-After', String(verdict.retryAfter))
|
||||
return reply.tooManyRequests(
|
||||
`Too many attempts. Try again in ${Math.ceil(verdict.retryAfter / 60)} minute(s).`
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { pageWatching as watchingTable, pages as pagesTable } from '../db/schema.ts'
|
||||
|
||||
/** A watched page, as the inbox lists it. */
|
||||
export interface WatchedPage {
|
||||
pageId: string
|
||||
path: string
|
||||
locale: string
|
||||
title: string
|
||||
description: string | null
|
||||
icon: string | null
|
||||
/** When the page itself last changed, which is what a watcher is watching FOR. */
|
||||
updatedAt: Date
|
||||
/** When this person started watching, i.e. how long they have been asking to be told. */
|
||||
watchedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Page watching model
|
||||
*
|
||||
* Who has asked to be told about which pages. Nothing is sent yet — notifications are not built — so
|
||||
* this is the list and nothing more: the bell on a page reads it, the inbox lists it, and whatever
|
||||
* delivers the news later has one place to ask who wants it.
|
||||
*/
|
||||
class PageWatching {
|
||||
/**
|
||||
* Whether this user is watching this page.
|
||||
*
|
||||
* Answered for the page view, which asks about every page it draws, so it is a single indexed lookup
|
||||
* on the pair — and not asked at all for a guest, who cannot watch anything.
|
||||
*/
|
||||
async isWatching(pageId: string, userId: string | null): Promise<boolean> {
|
||||
if (!userId) {
|
||||
return false
|
||||
}
|
||||
const rows = await WIKI.db
|
||||
.select({ id: watchingTable.id })
|
||||
.from(watchingTable)
|
||||
.where(and(eq(watchingTable.pageId, pageId), eq(watchingTable.userId, userId)))
|
||||
.limit(1)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching a page.
|
||||
*
|
||||
* Idempotent: watching a page one is already watching is what the reader asked for, and the unique
|
||||
* index turns the second row into nothing rather than into an error.
|
||||
*/
|
||||
async watch({
|
||||
siteId,
|
||||
pageId,
|
||||
userId
|
||||
}: {
|
||||
siteId: string
|
||||
pageId: string
|
||||
userId: string
|
||||
}): Promise<void> {
|
||||
await WIKI.db
|
||||
.insert(watchingTable)
|
||||
.values({ siteId, pageId, userId })
|
||||
.onConflictDoNothing({ target: [watchingTable.pageId, watchingTable.userId] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching a page. Also idempotent, for the same reason: the outcome asked for is that no row
|
||||
* exists, and it does not.
|
||||
*/
|
||||
async unwatch({ pageId, userId }: { pageId: string; userId: string }): Promise<void> {
|
||||
await WIKI.db
|
||||
.delete(watchingTable)
|
||||
.where(and(eq(watchingTable.pageId, pageId), eq(watchingTable.userId, userId)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages this user watches on a site, most recently watched first.
|
||||
*
|
||||
* Joined to the pages rather than storing a copy of the title and the path, so a page that is
|
||||
* renamed or moved is listed where it is now — which is the point of watching it. A deleted page
|
||||
* takes its rows with it through the foreign key, so nothing here can point at one that is gone.
|
||||
*/
|
||||
async listForUser(siteId: string, userId: string): Promise<WatchedPage[]> {
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
pageId: pagesTable.id,
|
||||
path: pagesTable.path,
|
||||
locale: pagesTable.locale,
|
||||
title: pagesTable.title,
|
||||
description: pagesTable.description,
|
||||
icon: pagesTable.icon,
|
||||
updatedAt: pagesTable.updatedAt,
|
||||
watchedAt: watchingTable.createdAt
|
||||
})
|
||||
.from(watchingTable)
|
||||
.innerJoin(pagesTable, eq(pagesTable.id, watchingTable.pageId))
|
||||
.where(and(eq(watchingTable.userId, userId), eq(watchingTable.siteId, siteId)))
|
||||
.orderBy(desc(watchingTable.createdAt))
|
||||
return rows as WatchedPage[]
|
||||
}
|
||||
}
|
||||
|
||||
export const pageWatching = new PageWatching()
|
||||
@ -0,0 +1,121 @@
|
||||
import { lt, sql } from 'drizzle-orm'
|
||||
import { rateLimits as rateLimitsTable } from '../db/schema.ts'
|
||||
|
||||
/** How a limit is configured: what it allows, over how long, and what it costs to exceed. */
|
||||
export interface RateLimitPolicy {
|
||||
/** Attempts allowed within the window. The one that exceeds it is what earns the ban. */
|
||||
max: number
|
||||
/** Length of the window, in seconds. */
|
||||
windowSeconds: number
|
||||
/** How long the ban lasts once earned, in seconds. */
|
||||
banSeconds: number
|
||||
}
|
||||
|
||||
/** What the caller does about an attempt. */
|
||||
export interface RateLimitVerdict {
|
||||
/** Whether the attempt may proceed. */
|
||||
allowed: boolean
|
||||
/** Attempts made in the current window, the one just counted included. */
|
||||
hits: number
|
||||
/** Seconds until the client may try again. Zero while it is still allowed. */
|
||||
retryAfter: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limits model
|
||||
*
|
||||
* A fixed window per key, with a ban for going over it — the counter that stands behind the login and
|
||||
* the other endpoints where guessing is the attack (`helpers/rateLimit.ts` is what applies it).
|
||||
*
|
||||
* Every attempt is one statement, and the statement decides everything: whether the window has rolled
|
||||
* over, whether the ban has lifted, what the count now is, and whether this attempt has just earned a
|
||||
* ban. Doing it in the database rather than in the process is the whole point — two instances behind
|
||||
* a load balancer share one counter, and a ban issued by one is honoured by both.
|
||||
*/
|
||||
class RateLimits {
|
||||
/**
|
||||
* Count an attempt against a key, and say whether it may proceed.
|
||||
*
|
||||
* The row is read, rolled over, incremented and possibly banned in a single upsert, so concurrent
|
||||
* attempts cannot both read the same count and both decide they are under the limit. What comes
|
||||
* back is the row as it now stands.
|
||||
*
|
||||
* A banned key stops counting: its ban runs for exactly as long as it was set for, rather than
|
||||
* being pushed further out by every attempt made during it. When the ban lifts, the window starts
|
||||
* again from nothing — having served it, a client is not one attempt away from serving another.
|
||||
*
|
||||
* @param key What is being limited and who by, e.g. `auth:203.0.113.4`
|
||||
*/
|
||||
async consume(key: string, policy: RateLimitPolicy): Promise<RateLimitVerdict> {
|
||||
const window = sql`make_interval(secs => ${policy.windowSeconds})`
|
||||
/*
|
||||
The three cases, in the order the CASE arms decide them:
|
||||
|
||||
1. still banned -- nothing changes; the attempt is refused
|
||||
2. window rolled over, or a ban has just expired -- the row starts again at this attempt
|
||||
3. otherwise -- one more attempt, and a ban if that is one too many
|
||||
|
||||
`"rateLimits"` rather than the aliased `excluded`: these have to read the row AS IT WAS, and
|
||||
`excluded` is the row this statement proposed.
|
||||
*/
|
||||
const rolledOver = sql`"rateLimits"."bannedUntil" is not null or "rateLimits"."windowStartedAt" <= now() - ${window}`
|
||||
const stillBanned = sql`"rateLimits"."bannedUntil" > now()`
|
||||
const rows = await WIKI.db.execute(sql`
|
||||
insert into "rateLimits" ("key", "hits", "windowStartedAt", "updatedAt")
|
||||
values (${key}, 1, now(), now())
|
||||
on conflict ("key") do update set
|
||||
"hits" = case
|
||||
when ${stillBanned} then "rateLimits"."hits"
|
||||
when ${rolledOver} then 1
|
||||
else "rateLimits"."hits" + 1
|
||||
end,
|
||||
"windowStartedAt" = case
|
||||
when ${stillBanned} then "rateLimits"."windowStartedAt"
|
||||
when ${rolledOver} then now()
|
||||
else "rateLimits"."windowStartedAt"
|
||||
end,
|
||||
"bannedUntil" = case
|
||||
when ${stillBanned} then "rateLimits"."bannedUntil"
|
||||
when ${rolledOver} then null
|
||||
when "rateLimits"."hits" + 1 > ${policy.max} then now() + make_interval(secs => ${policy.banSeconds})
|
||||
else null
|
||||
end,
|
||||
"updatedAt" = now()
|
||||
returning
|
||||
"hits",
|
||||
"bannedUntil" > now() as "isBanned",
|
||||
greatest(0, ceil(extract(epoch from coalesce("bannedUntil", now()) - now())))::int as "retryAfter"
|
||||
`)
|
||||
const row = (rows as any).rows?.[0] ?? (rows as any)[0]
|
||||
return {
|
||||
allowed: !row?.isBanned,
|
||||
hits: Number(row?.hits ?? 0),
|
||||
retryAfter: row?.isBanned ? Number(row.retryAfter) : 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget a key, e.g. once the attempt it was counting has succeeded.
|
||||
*/
|
||||
async reset(key: string): Promise<void> {
|
||||
await WIKI.db.delete(rateLimitsTable).where(sql`${rateLimitsTable.key} = ${key}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop rows nothing has touched for a day.
|
||||
*
|
||||
* Only ever about reclaiming space: a stale row is already harmless, since the next attempt on that
|
||||
* key rolls its window over before reading it. A key nobody has used in a day is one nobody is
|
||||
* being limited by.
|
||||
*
|
||||
* @returns How many rows were dropped
|
||||
*/
|
||||
async purgeStale(): Promise<number> {
|
||||
const result = await WIKI.db
|
||||
.delete(rateLimitsTable)
|
||||
.where(lt(rateLimitsTable.updatedAt, sql`now() - interval '1 day'`))
|
||||
return result.rowCount ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
export const rateLimits = new RateLimits()
|
||||
@ -0,0 +1,13 @@
|
||||
export async function task(): Promise<void> {
|
||||
WIKI.logger.info('Purging stale rate limit counters...')
|
||||
|
||||
try {
|
||||
const purged = await WIKI.models.rateLimits.purgeStale()
|
||||
|
||||
WIKI.logger.info(`Purged ${purged} stale rate limit counters: [ COMPLETED ]`)
|
||||
} catch (err: any) {
|
||||
WIKI.logger.error('Purging stale rate limit counters: [ FAILED ]')
|
||||
WIKI.logger.error(err.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue