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({
mode: 'standalone',
}),
security: {
checkOrigin: true,
},
server: {
host: true,
},

View file

@ -18,6 +18,7 @@ export const env = zodValidateSync(
FAKE_DEEPL_SECRET: z.string(),
MK_WEBHOOK_SECRET: z.string(),
QBT_WEBHOOK_SECRET: z.string(),
CSRF_SECRET: z.string(),
}),
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 { getRequestIp } from '~/backend/utils/request'
import { getCsrfToken } from '~/backend/utils/csrf'
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'))
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
client:idle
csrf={csrf}
shoutError={shoutError}
initPage={page}
initPageData={data}
/>

View file

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

View file

@ -3,9 +3,11 @@ import { z } from 'zod'
import { fromError } from 'zod-validation-error'
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({
_csrf: z.string(),
message: z.string(),
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({
fromIp: getRequestIp(ctx),
fromIp: ip,
private: body.data.private === '',
text: body.data.message,
})