fix: rate limit for shoutbox

This commit is contained in:
alina 🌸 2024-08-06 22:36:58 +03:00
parent 0951c4e1d0
commit 3f1049c6f5
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
4 changed files with 66 additions and 32 deletions

View file

@ -26,6 +26,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-kit": "^0.23.1", "drizzle-kit": "^0.23.1",
"drizzle-orm": "^0.32.1", "drizzle-orm": "^0.32.1",
"rate-limiter-flexible": "^5.0.3",
"solid-js": "^1.8.19", "solid-js": "^1.8.19",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"zod": "^3.23.8", "zod": "^3.23.8",

View file

@ -50,6 +50,9 @@ importers:
drizzle-orm: drizzle-orm:
specifier: ^0.32.1 specifier: ^0.32.1
version: 0.32.1(@types/better-sqlite3@7.6.11)(better-sqlite3@11.1.2) version: 0.32.1(@types/better-sqlite3@7.6.11)(better-sqlite3@11.1.2)
rate-limiter-flexible:
specifier: ^5.0.3
version: 5.0.3
solid-js: solid-js:
specifier: ^1.8.19 specifier: ^1.8.19
version: 1.8.19 version: 1.8.19
@ -3091,6 +3094,9 @@ packages:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
rate-limiter-flexible@5.0.3:
resolution: {integrity: sha512-lWx2y8NBVlTOLPyqs+6y7dxfEpT6YFqKy3MzWbCy95sTTOhOuxufP2QvRyOHpfXpB9OUJPbVLybw3z3AVAS5fA==}
rc@1.2.8: rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true hasBin: true
@ -7066,6 +7072,8 @@ snapshots:
range-parser@1.2.1: {} range-parser@1.2.1: {}
rate-limiter-flexible@5.0.3: {}
rc@1.2.8: rc@1.2.8:
dependencies: dependencies:
deep-extend: 0.6.0 deep-extend: 0.6.0

View file

@ -0,0 +1,28 @@
export class HttpResponse {
private constructor() {}
static json(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), {
...init,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
})
}
static error(status: number) {
return new Response(null, { status })
}
static redirect(url: string, init?: ResponseInit) {
return new Response(null, {
status: 301,
...init,
headers: {
Location: url,
...init?.headers,
},
})
}
}

View file

@ -1,10 +1,12 @@
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
import { z } from 'zod' import { z } from 'zod'
import { fromError } from 'zod-validation-error' import { fromError } from 'zod-validation-error'
import { RateLimiterMemory } from 'rate-limiter-flexible'
import { createShout, fetchShouts } from '~/backend/service/shoutbox' import { createShout, fetchShouts } from '~/backend/service/shoutbox'
import { getRequestIp } from '~/backend/utils/request' import { getRequestIp } from '~/backend/utils/request'
import { verifyCsrfToken } from '~/backend/utils/csrf' import { verifyCsrfToken } from '~/backend/utils/csrf'
import { HttpResponse } from '~/backend/utils/response'
const schema = z.object({ const schema = z.object({
_csrf: z.string(), _csrf: z.string(),
@ -12,6 +14,9 @@ const schema = z.object({
private: z.literal('').optional(), private: z.literal('').optional(),
}) })
const rateLimitPerIp = new RateLimiterMemory({ points: 3, duration: 300 })
const rateLimitGlobal = new RateLimiterMemory({ points: 100, duration: 3600 })
export const POST: APIRoute = async (ctx) => { export const POST: APIRoute = async (ctx) => {
const contentType = ctx.request.headers.get('content-type') const contentType = ctx.request.headers.get('content-type')
const isFormSubmit = contentType === 'application/x-www-form-urlencoded' const isFormSubmit = contentType === 'application/x-www-form-urlencoded'
@ -25,27 +30,30 @@ export const POST: APIRoute = async (ctx) => {
const body = await schema.safeParseAsync(bodyRaw) const body = await schema.safeParseAsync(bodyRaw)
if (body.error) { if (body.error) {
return new Response(JSON.stringify({ return HttpResponse.json({
error: fromError(body.error).message, error: fromError(body.error).message,
}), { }, { status: 400 })
status: 400,
headers: {
'Content-Type': 'application/json',
},
})
} }
const ip = getRequestIp(ctx) const ip = getRequestIp(ctx)
if (!verifyCsrfToken(ip, body.data._csrf)) { if (!verifyCsrfToken(ip, body.data._csrf)) {
return new Response(JSON.stringify({ return HttpResponse.json({
error: 'csrf token is invalid', error: 'csrf token is invalid',
}), { }, { status: 400 })
status: 400, }
headers: {
'Content-Type': 'application/json', const remainingLocal = await rateLimitPerIp.get(ip)
}, const remainingGlobal = await rateLimitGlobal.get('GLOBAL')
}) if (remainingLocal?.remainingPoints === 0) {
return HttpResponse.json({
error: 'too many requests',
}, { status: 400 })
}
if (remainingGlobal?.remainingPoints === 0) {
return HttpResponse.json({
error: `too many requests (globally), please retry after ${Math.ceil(remainingGlobal.msBeforeNext) / 60_000} minutes`,
}, { status: 400 })
} }
const result = await createShout({ const result = await createShout({
@ -54,23 +62,16 @@ export const POST: APIRoute = async (ctx) => {
text: body.data.message, text: body.data.message,
}) })
await rateLimitPerIp.penalty(ip, 1)
await rateLimitGlobal.penalty('GLOBAL', 1)
if (isFormSubmit) { if (isFormSubmit) {
return new Response(null, { return HttpResponse.redirect(typeof result === 'string' ? `/?shout_error=${result}` : '/')
status: 301,
headers: {
Location: typeof result === 'string' ? `/?shout_error=${result}` : '/',
},
})
} }
return new Response(JSON.stringify( return HttpResponse.json(
typeof result === 'string' ? { error: result } : { ok: true }, typeof result === 'string' ? { error: result } : { ok: true },
), { )
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
} }
export const GET: APIRoute = async (ctx) => { export const GET: APIRoute = async (ctx) => {
@ -81,9 +82,5 @@ export const GET: APIRoute = async (ctx) => {
const data = fetchShouts(page, getRequestIp(ctx)) const data = fetchShouts(page, getRequestIp(ctx))
return new Response(JSON.stringify(data), { return HttpResponse.json(data)
headers: {
'Content-Type': 'application/json',
},
})
} }