fix: better csrf handling
This commit is contained in:
parent
8b54503399
commit
e6a632cdf3
6 changed files with 71 additions and 10 deletions
|
@ -17,9 +17,6 @@ export default defineConfig({
|
|||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
security: {
|
||||
checkOrigin: true,
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
},
|
||||
|
|
|
@ -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
40
src/backend/utils/csrf.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue