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