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({
|
adapter: node({
|
||||||
mode: 'standalone',
|
mode: 'standalone',
|
||||||
}),
|
}),
|
||||||
security: {
|
|
||||||
checkOrigin: true,
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
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 { 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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue