fix: better csrf handling

This commit is contained in:
alina 🌸 2024-08-03 09:18:52 +03:00
parent 8b54503399
commit e6a632cdf3
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
6 changed files with 71 additions and 10 deletions

View file

@ -17,9 +17,6 @@ export default defineConfig({
adapter: node({ adapter: node({
mode: 'standalone', mode: 'standalone',
}), }),
security: {
checkOrigin: true,
},
server: { server: {
host: true, host: true,
}, },

View file

@ -18,6 +18,7 @@ export const env = zodValidateSync(
FAKE_DEEPL_SECRET: z.string(), FAKE_DEEPL_SECRET: z.string(),
MK_WEBHOOK_SECRET: z.string(), MK_WEBHOOK_SECRET: z.string(),
QBT_WEBHOOK_SECRET: z.string(), QBT_WEBHOOK_SECRET: z.string(),
CSRF_SECRET: z.string(),
}), }),
process.env, process.env,
) )

40
src/backend/utils/csrf.ts Normal file
View file

@ -0,0 +1,40 @@
import { createHmac, randomBytes } from 'node:crypto'
import { env } from '~/backend/env'
const secret = env.CSRF_SECRET
const validity = 300_000
export function getCsrfToken(ip: string) {
const data = Buffer.from(JSON.stringify([Date.now(), ip]))
const salt = randomBytes(8)
const sign = createHmac('sha256', secret).update(data).update(salt).digest()
return Buffer.concat([
data,
salt,
sign.subarray(0, 8),
]).toString('base64url')
}
export function verifyCsrfToken(ip: string, token: string) {
try {
const buf = Buffer.from(token, 'base64url')
if (buf.length < 16) return false
const saltedData = buf.subarray(0, -8)
const correctSign = createHmac('sha256', secret).update(saltedData).digest()
if (Buffer.compare(correctSign.subarray(0, 8), buf.subarray(-8)) !== 0) {
return false
}
const [issued, correctIp] = JSON.parse(buf.subarray(0, -16).toString())
if (issued + validity < Date.now()) return false
if (ip !== correctIp) return false
return true
} catch {
return false
}
}

View file

@ -1,6 +1,7 @@
--- ---
import { fetchShouts } from '~/backend/service/shoutbox' import { fetchShouts } from '~/backend/service/shoutbox'
import { getRequestIp } from '~/backend/utils/request' import { getRequestIp } from '~/backend/utils/request'
import { getCsrfToken } from '~/backend/utils/csrf'
import { Shoutbox as ShoutboxSolid } from './Shoutbox' import { Shoutbox as ShoutboxSolid } from './Shoutbox'
@ -8,11 +9,17 @@ const url = new URL(Astro.request.url)
let page = Number(url.searchParams.get('shouts_page')) let page = Number(url.searchParams.get('shouts_page'))
if (Number.isNaN(page)) page = 0 if (Number.isNaN(page)) page = 0
const data = fetchShouts(page, getRequestIp(Astro)) const shoutError = url.searchParams.get('shout_error') ?? undefined
const ip = getRequestIp(Astro)
const data = fetchShouts(page, ip)
const csrf = getCsrfToken(ip)
--- ---
<ShoutboxSolid <ShoutboxSolid
client:idle client:idle
csrf={csrf}
shoutError={shoutError}
initPage={page} initPage={page}
initPageData={data} initPageData={data}
/> />

View file

@ -24,6 +24,8 @@ async function fetchShouts(page: number): Promise<ShoutsData> {
function ShoutboxInner(props: { function ShoutboxInner(props: {
initPage: number initPage: number
initPageData: ShoutsData initPageData: ShoutsData
shoutError?: string
csrf: string
}) { }) {
// eslint-disable-next-line solid/reactivity // eslint-disable-next-line solid/reactivity
const [page, setPage] = createSignal(props.initPage) const [page, setPage] = createSignal(props.initPage)
@ -89,7 +91,7 @@ function ShoutboxInner(props: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
// _csrf: shouts.data?.csrf, _csrf: props.csrf,
message: (form.elements.namedItem('message') as HTMLInputElement).value, message: (form.elements.namedItem('message') as HTMLInputElement).value,
private: isPrivate ? '' : undefined, private: isPrivate ? '' : undefined,
}), }),
@ -124,7 +126,7 @@ function ShoutboxInner(props: {
</TextComment> </TextComment>
<form action="/api/shoutbox" class={css.form} method="post" ref={form}> <form action="/api/shoutbox" class={css.form} method="post" ref={form}>
{/* <input type="hidden" name="_csrf" value={shouts.data.csrf} /> */} <input type="hidden" name="_csrf" value={props.csrf} />
<div class={css.formInput}> <div class={css.formInput}>
<TextArea <TextArea
disabled={sending()} disabled={sending()}
@ -132,8 +134,7 @@ function ShoutboxInner(props: {
grow grow
maxRows={5} maxRows={5}
name="message" name="message"
// placeholder={initData.shoutError || 'let the void hear you'} placeholder={props.shoutError || 'let the void hear you'}
placeholder="let the void hear you"
required required
/> />

View file

@ -3,9 +3,11 @@ import { z } from 'zod'
import { fromError } from 'zod-validation-error' import { fromError } from 'zod-validation-error'
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'
const schema = z.object({ const schema = z.object({
_csrf: z.string(),
message: z.string(), message: z.string(),
private: z.literal('').optional(), private: z.literal('').optional(),
}) })
@ -33,8 +35,21 @@ export const POST: APIRoute = async (ctx) => {
}) })
} }
const ip = getRequestIp(ctx)
if (!verifyCsrfToken(ip, body.data._csrf)) {
return new Response(JSON.stringify({
error: 'csrf token is invalid',
}), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
})
}
const result = await createShout({ const result = await createShout({
fromIp: getRequestIp(ctx), fromIp: ip,
private: body.data.private === '', private: body.data.private === '',
text: body.data.message, text: body.data.message,
}) })