anti-csrf
Massimo Melina committed
Jan 11, 2022 at 15:45 UTC
f1d53ecf5e68d2a7a548bb87d5439d264b7da8d1
7 files changed
+68
-16
frontend/package-lock.json
+2
-2
@@ -1,12 +1,12 @@
1
{
2
"name": "frontend",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
"lockfileVersion": 2,
5
"requires": true,
6
"packages": {
7
"": {
8
"name": "frontend",
9
- "version": "0.4.0",
9
+ "version": "0.6.0",
10
"dependencies": {
11
"js-sha512": "^0.8.0",
12
"lodash": "^4.17.21",
frontend/src/api.ts
+11
-3
@@ -1,11 +1,12 @@
1
import { useEffect, useState } from 'react';
2
-import { Falsy, working } from './misc'
2
+import { Falsy, getCookie, working } from './misc'
3
4
const PREFIX = '/~/api/'
5
6
interface ApiCallOptions { noModal?:true }
7
-export function apiCall(cmd: string, params?: object, options: ApiCallOptions={}) : Promise<any> {
7
+export function apiCall(cmd: string, params?: Record<string,any>, options: ApiCallOptions={}) : Promise<any> {
8
const stop = options.noModal ? undefined : working()
9
+ params = addCsrf(params)
10
return fetch(PREFIX+cmd, {
11
method: 'POST',
12
headers: { 'content-type': 'application/json' },
@@ -48,7 +49,7 @@ export function apiEvents(cmd: string, params: Record<string,any>, cb:EventHandl
49
if (v === undefined) continue
50
processed[k] = v === true ? '1' : v
51
}
51
- const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(processed))
52
+ const source = new EventSource(PREFIX + cmd + '?' + new URLSearchParams(addCsrf(processed)))
53
source.onopen = () => cb('connected')
54
source.onerror = err => cb('error', err)
55
source.onmessage = ({ data }) => {
@@ -65,3 +66,10 @@ export function apiEvents(cmd: string, params: Record<string,any>, cb:EventHandl
66
return source
67
}
68
69
+function addCsrf(params?: Record<string,any>) {
70
+ const csrf = getCookie('csrf')
71
+ if (!csrf)
72
+ return params
73
+ console.log({ csrf })
74
+ return { csrf, ...params }
75
+}
frontend/src/misc.ts
+12
@@ -79,3 +79,15 @@ export function useForceUpdate(): [()=>void, number] {
79
const [n, setN] = useState(0)
80
return [ useCallback(()=> setN(n => n+1), [setN]), n ]
81
}
82
+
83
+export function getCookie(name: string) {
84
+ const pre = name + '='
85
+ let decodedCookie = decodeURIComponent(document.cookie)
86
+ let ca = decodedCookie.split(';')
87
+ for (let c of ca) {
88
+ c = c.trim()
89
+ if (c.startsWith(pre))
90
+ return c.substring(pre.length, c.length)
91
+ }
92
+ return ''
93
+}
src/api.auth.ts
+21
-5
@@ -1,14 +1,30 @@
1
-import { getAccount, saveSrpInfo, updateAccount } from './perm'
1
+import { getAccount, getCurrentUsername, saveSrpInfo, updateAccount } from './perm'
2
import { verifyPassword } from './crypt'
3
import { CFG_ALLOW_CLEAR_TEXT_LOGIN, getConfig } from './config'
4
import { ApiError, ApiHandler } from './apis'
5
import { SRPParameters, SRPRoutines, SRPServerSession, SRPServerSessionStep1 } from 'tssrp6a'
6
import { SESSION_DURATION } from './index'
7
+import { randomId } from './misc'
8
+import Koa from 'koa'
9
10
const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
11
const srpSession = new SRPServerSession(srp6aNimbusRoutines)
12
const ongoingLogins:Record<string,SRPServerSessionStep1> = {}
13
14
+// centralized log-in state
15
+function loggedIn(ctx:Koa.Context, username: string | false) {
16
+ const s = ctx.session
17
+ if (!s)
18
+ return ctx.throw(500,'session')
19
+ if (username === false) {
20
+ delete s.username
21
+ ctx.cookies.set('csrf', '')
22
+ return
23
+ }
24
+ s.username = username
25
+ ctx.cookies.set('csrf', randomId(), { signed:false, httpOnly: false })
26
+}
27
+
28
function makeExp() {
29
return { exp: new Date(Date.now() + SESSION_DURATION) }
30
}
@@ -28,7 +44,7 @@ export const login: ApiHandler = async ({ username, password }, ctx) => {
44
return new ApiError(401)
45
if (!ctx.session)
46
return new ApiError(500)
31
- ctx.session.username = username
47
+ loggedIn(ctx, username)
48
return makeExp()
49
}
50
@@ -61,7 +77,7 @@ export const loginSrp2: ApiHandler = async ({ pubKey, proof }, ctx) => {
77
const step1 = ongoingLogins[sid]
78
try {
79
const M2 = await step1.step2(BigInt(pubKey), BigInt(proof))
64
- ctx.session.username = username
80
+ loggedIn(ctx, username)
81
return { proof: String(M2), ...makeExp() }
82
}
83
catch(e) {
@@ -76,14 +92,14 @@ export const loginSrp2: ApiHandler = async ({ pubKey, proof }, ctx) => {
92
export const logout: ApiHandler = async ({}, ctx) => {
93
if (!ctx.session)
94
return new ApiError(500)
79
- delete ctx.session.username
95
+ loggedIn(ctx, false)
96
return {}
97
}
98
99
export const refresh_session: ApiHandler = async ({}, ctx) => {
100
if (!ctx.session)
101
return new ApiError(500)
86
- return { username: ctx.session.username, ...makeExp() }
102
+ return { username: getCurrentUsername(ctx), ...makeExp() }
103
}
104
105
export const change_password: ApiHandler = async ({ newPassword }, ctx) => {
src/apis.ts
+13
-5
@@ -19,16 +19,24 @@ export function apiMiddleware(apis: ApiHandlers) : Koa.Middleware {
19
}
20
let res
21
try {
22
- res = await apis[ctx.path](params || {}, ctx)
22
+ const csrf = ctx.cookies.get('csrf')
23
+ if (csrf && csrf !== params.csrf) // we don't rely on SameSite cookie option because it's https-only
24
+ res = new ApiError(401, 'csrf')
25
+ else
26
+ res = await apis[ctx.path](params || {}, ctx)
27
}
28
catch(e) {
29
ctx.throw(500, String(e))
30
}
31
if (res)
28
- if (res instanceof ApiError)
29
- ctx.throw(res.status, res.message)
30
- else if (res instanceof Error)
31
- ctx.throw(400, res)
32
+ if (res instanceof ApiError) {
33
+ ctx.body = res.message
34
+ ctx.status = res.status
35
+ }
36
+ else if (res instanceof Error) {
37
+ ctx.body = String(res)
38
+ ctx.status = 400
39
+ }
40
else
41
ctx.body = res
42
await next()
src/misc.ts
+9
@@ -60,3 +60,12 @@ export function getOrSet<T>(o:any, k:string, creator:()=>T): T {
60
return k in o ? o[k]
61
: (o[k] = creator())
62
}
63
+
64
+export function randomId(len = 10) {
65
+ // 10 chars is 51+bits, the max we can give. 8 is 41+bits
66
+ if (len > 10) throw Error('bad length');
67
+ return Math.random()
68
+ .toString(36)
69
+ .substring(2, 2+len)
70
+ .replace(/l/g, 'L'); // avoid confusion reading l1
71
+}
todo.md
-1
@@ -1,7 +1,6 @@
1
# To do
2
- download counter (as plugin?)
3
- update tests to SRP login
4
-- anti-csrf
4
- upload
5
- upload unzipping (while streaming?)
6
- https