fix: antibrute plugin was not effective against unencrypted logins
Massimo Melina committed
May 5, 2024 at 00:11 UTC
23600aa2e36ee26d0a5d5413e67da14d4f4ef8ec
14 files changed
+129
-113
plugins/antibrute/plugin.js
+23
-23
@@ -1,6 +1,6 @@
1
-exports.version = 2.1
1
+exports.version = 3
2
exports.description = "Introduce increasing delays between login attempts."
3
-exports.apiRequired = 3 // log
3
+exports.apiRequired = 8.8 // attemptingLogin
4
5
exports.config = {
6
increment: { type: 'number', min: 1, defaultValue: 5, helperText: "Seconds to add to the delay for each login attempt" },
@@ -13,29 +13,29 @@ exports.configDialog = {
13
const byIp = {}
14
15
exports.init = api => {
16
- const LOGIN_URI = api.Const.API_URI + 'loginSrp1'
16
const { getOrSet } = api.require('./misc')
18
- const { getCurrentUsername } = api.require('./auth')
17
return {
20
- async middleware(ctx) {
21
- const { ip } = ctx
22
- if (getCurrentUsername(ctx)) // login was successful
23
- delete byIp[ip]
24
- if (ctx.path !== LOGIN_URI) return
25
- const now = Date.now()
26
- const rec = getOrSet(byIp, ip, () => ({ delay: 0, next: now }))
27
- const wait = rec.next - now
28
- const max = api.getConfig('max') * 1000
29
- const inc = api.getConfig('increment') * 1000
30
- rec.delay = Math.min(max, rec.delay + inc)
31
- rec.next += rec.delay
32
- clearTimeout(rec.timer)
33
- if (wait > 0) {
34
- api.log('delaying', ip, 'for', Math.round(wait / 1000))
35
- ctx.set('x-anti-brute-force', wait)
36
- await new Promise(resolve => setTimeout(resolve, wait))
18
+ unload: api.events.multi({
19
+ attemptingLogin: async ctx => {
20
+ const { ip } = ctx
21
+ const now = new Date
22
+ const rec = getOrSet(byIp, ip, () => ({ attempts: 0, next: now }))
23
+ const max = api.getConfig('max') * 1000
24
+ const delay = Math.min(max, 1000 * api.getConfig('increment') * ++rec.attempts)
25
+ const wait = rec.next - now
26
+ rec.next = new Date(+rec.next + delay)
27
+ clearTimeout(rec.timer)
28
+ if (wait > 0) {
29
+ api.log('delaying', ip, 'for', Math.round(wait / 1000))
30
+ ctx.set('x-anti-brute-force', wait)
31
+ await new Promise(resolve => setTimeout(resolve, wait))
32
+ }
33
+ rec.timer = setTimeout(() => delete byIp[ip], max * 10) // no memory leak
34
+ },
35
+ login: ctx => {
36
+ if (ctx.state.account)
37
+ delete byIp[ctx.ip] // reset if login was successful
38
}
38
- rec.timer = setTimeout(() => delete byIp[ip], rec.delay * 10) // no memory leak
39
- }
39
+ })
40
}
41
}
shared/api.ts
+4
-3
@@ -2,8 +2,9 @@
2
3
import _ from 'lodash';
4
import { useCallback, useEffect, useMemo, useRef } from 'react';
5
-import { Callback, Dict, EventEmitter, Falsy, getPrefixUrl, pendingPromise, useStateMounted, wait,
5
+import { Callback, Dict, Falsy, getPrefixUrl, pendingPromise, useStateMounted, wait,
6
buildUrlQueryString, } from '.'
7
+import { BetterEventEmitter } from '../src/events'
8
9
export const API_URL = '/~/api/'
10
@@ -112,9 +113,9 @@ export function useApi<T=any>(cmd: string | Falsy, params?: object, options: Api
113
setForcer(v => v + 1)
114
reloadingRef.current = pendingPromise()
115
}, [setForcer])
115
- const ee = useMemo(() => new EventEmitter, [])
116
+ const ee = useMemo(() => new BetterEventEmitter, [])
117
const sub = useCallback((cb: Callback) => ee.on('data', cb), [])
117
- useEffect(() => ee.emit('data'), [data])
118
+ useEffect(() => { ee.emit('data') }, [data])
119
return { data, setData, error, reload, sub, loading: loadingRef.current || reloadingRef.current, getData: () => dataRef.current, }
120
}
121
shared/index.ts
+1
-14
@@ -2,7 +2,7 @@
2
3
import _ from 'lodash'
4
import { apiCall } from './api'
5
-import { Callback, DAY, Dict, getOrSet, HOUR, MINUTE, objSameKeys, typedEntries } from '../src/cross'
5
+import { DAY, Dict, HOUR, MINUTE, objSameKeys, typedEntries } from '../src/cross'
6
export * from './react'
7
export * from './dialogs'
8
export * from './md'
@@ -154,19 +154,6 @@ export function createDurationFormatter({ locale=undefined, unitDisplay='narrow'
154
}
155
}
156
157
-// basic event emitter without names
158
-export class EventEmitter {
159
- listeners = {} as Record<string, Callback[]>
160
- emit(name: string, ...args: any[]) {
161
- for (const cb of this.listeners[name] || []) cb(...args)
162
- }
163
- on(name: string, cb: Callback) {
164
- const q = getOrSet(this.listeners, name, () => [])
165
- q.push(cb)
166
- return () => _.pull(q, cb)
167
- }
168
-}
169
-
157
Element.prototype.replaceChildren ||= function(this:Element, addNodes) { // polyfill
158
while (this.lastChild) this.removeChild(this.lastChild);
159
if (addNodes !== undefined) this.append(addNodes);
src/SendList.ts
+2
-4
@@ -2,7 +2,6 @@ import { Readable } from 'stream'
2
import _ from 'lodash'
3
import { LIST, wantArray } from './cross'
4
import { Context } from 'koa'
5
-import { onOff } from './misc'
5
import events from './events'
6
7
type SendListFunc<T> = (list:SendListReadable<T>) => void
@@ -109,9 +108,8 @@ export class SendListReadable<T> extends Readable {
108
this.processBuffer.flush()
109
this.push(null)
110
}
112
- events(ctx: Context, eventMap: Parameters<typeof onOff>[1]) {
113
- const off = onOff(events, eventMap)
114
- ctx.res.once('close', off)
111
+ events(ctx: Context, eventMap: Parameters<typeof events.multi>[0]) {
112
+ ctx.res.once('close', events.multi(eventMap))
113
return this
114
}
115
isClosed() {
src/api.auth.ts
+2
@@ -8,6 +8,7 @@ import { ctxAdminAccess } from './adminApis'
8
import { sessionDuration } from './middlewares'
9
import { getCurrentUsername, setLoggedIn, srpStep1 } from './auth'
10
import { defineConfig } from './config'
11
+import events from './events'
12
13
const ongoingLogins:Record<string,SRPServerSessionStep1> = {} // store data that doesn't fit session object
14
const keepSessionAlive = defineConfig('keep_session_alive', true)
@@ -18,6 +19,7 @@ export const loginSrp1: ApiHandler = async ({ username }, ctx) => {
19
const account = getAccount(username)
20
if (!ctx.session)
21
return new ApiError(HTTP_SERVER_ERROR)
22
+ await events.emitAsync('attemptingLogin', ctx)
23
if (!account || !accountCanLogin(account)) { // TODO simulate fake account to prevent knowing valid usernames
24
ctx.logExtra({ u: username })
25
ctx.state.dontLog = false // log even if log_api is false
src/api.log.ts
+2
-6
@@ -4,7 +4,6 @@ import { consoleLog } from './consoleLog'
4
import { HTTP_NOT_ACCEPTABLE, HTTP_NOT_FOUND, wait } from './cross'
5
import events from './events'
6
import { loggers } from './log'
7
-import { onOff } from './misc'
7
import { SendListReadable } from './SendList'
8
import { serveFile } from './serveFile'
9
@@ -41,11 +40,8 @@ export default {
40
if (!_.find(loggers, { name: file }))
41
return list.error(HTTP_NOT_FOUND, true)
42
list.ready()
44
- ctx.res.once('close', onOff(events, { // unsubscribe when connection is interrupted
45
- [file](entry) {
46
- list.add(entry)
47
- }
48
- }))
43
+ // unsubscribe when connection is interrupted
44
+ ctx.res.once('close', events.on(file, x => list.add(x)))
45
}
46
})
47
src/api.plugins.ts
+19
-20
@@ -7,9 +7,8 @@ import {
7
} from './plugins'
8
import _ from 'lodash'
9
import assert from 'assert'
10
-import { Callback, HTTP_CONFLICT, newObj, onOff, waitFor } from './misc'
10
+import { HTTP_CONFLICT, newObj, waitFor } from './misc'
11
import { ApiError, ApiHandlers } from './apiMiddleware'
12
-import events from './events'
12
import { rm } from 'fs/promises'
13
import { downloadPlugin, getFolder2repo, readOnlineCompatiblePlugin, readOnlinePlugin, searchPlugins } from './github'
14
import { HTTP_FAILED_DEPENDENCY, HTTP_NOT_FOUND, HTTP_SERVER_ERROR } from './const'
@@ -94,31 +93,31 @@ const apis: ApiHandlers = {
93
get_online_plugins({ text }, ctx) {
94
return new SendListReadable({
95
async doAtStart(list) {
96
+ const repos = [] as string[]
97
+ list.events(ctx, {
98
+ pluginInstalled: p => {
99
+ if (repos.includes(p.repo))
100
+ list.update({ id: p.repo }, { installed: true })
101
+ },
102
+ pluginUninstalled: folder => {
103
+ const repo = getFolder2repo()[folder]
104
+ if (typeof repo !== 'string') return // custom repo
105
+ if (repos.includes(repo))
106
+ list.update({ id: repo }, { installed: false })
107
+ },
108
+ pluginDownload({ id, status }) {
109
+ if (repos.includes(id))
110
+ list.update({ id }, { downloading: status ?? null })
111
+ }
112
+ })
113
try {
98
- // avoid creating N listeners on ctx.req, and getting a warning
99
- const undo: Callback[] = []
100
- ctx.req.once('close', () => undo.forEach(x => x()))
101
-
114
const already = Object.values(getFolder2repo()).map(String)
115
for await (const pl of await searchPlugins(text, { skipRepos: already })) {
116
const repo = pl.repo || pl.id // .repo property can be more trustworthy in case github user renamed and left the previous link in 'repo'
117
const missing = await getMissingDependencies(pl)
118
if (missing.length) pl.missing = missing
119
list.add(pl)
108
- // watch for events about this plugin, until this request is closed
109
- undo.push(onOff(events, {
110
- pluginInstalled: p => {
111
- if (p.repo === repo)
112
- list.update({ id: repo }, { installed: true })
113
- },
114
- pluginUninstalled: folder => {
115
- if (repo === getFolder2repo()[folder])
116
- list.update({ id: repo }, { installed: false })
117
- },
118
- ['pluginDownload_' + repo](status) {
119
- list.update({ id: repo }, { downloading: status ?? null })
120
- }
121
- }))
120
+ repos.push(repo)
121
}
122
} catch (err: any) {
123
list.error(err.code || err.message)
src/auth.ts
+3
@@ -5,6 +5,7 @@ import { Context } from 'koa'
5
import { srpClientPart } from './srp'
6
import { DAY, getOrSet } from './cross'
7
import { createHash } from 'node:crypto'
8
+import events from './events'
9
10
const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
11
@@ -43,6 +44,7 @@ export async function setLoggedIn(ctx: Context, username: string | false) {
44
if (!s)
45
return ctx.throw(HTTP_SERVER_ERROR,'session')
46
if (username === false) {
47
+ events.emit('logout', ctx)
48
delete s.username
49
return
50
}
@@ -52,6 +54,7 @@ export async function setLoggedIn(ctx: Context, username: string | false) {
54
s.ts = Date.now()
55
if (!a.expire && a.days_to_live)
56
updateAccount(a, { expire: new Date(Date.now() + a.days_to_live! * DAY) })
57
+ await events.emitAsync('login', ctx)
58
}
59
60
// since session are currently stored in cookies, we need to store this information
src/config.ts
+9
-15
@@ -1,11 +1,10 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import EventEmitter from 'events'
3
import { argv, ORIGINAL_CWD, VERSION } from './const'
4
import { watchLoad } from './watchLoad'
5
import yaml from 'yaml'
6
import _ from 'lodash'
8
-import { DAY, debounceAsync, newObj, onOff, throw_, tryJson, wait, with_ } from './misc'
7
+import { DAY, debounceAsync, newObj, throw_, tryJson, wait, with_ } from './misc'
8
import { statSync } from 'fs'
9
import { join, resolve } from 'path'
10
import events from './events'
@@ -18,8 +17,6 @@ const configProps: Record<string, { defaultValue?: unknown }> = {}
17
18
let started = false // this will tell the difference for subscribeConfig()s that are called before or after config is loaded
19
let state: Record<string, any> = {} // current state of config properties
21
-const cfgEvents = new EventEmitter()
22
-cfgEvents.setMaxListeners(10_000)
20
const filePath = with_(argv.config || process.env.HFS_CONFIG, p => {
21
if (!p)
22
return FILE
@@ -51,7 +48,7 @@ class Version extends String {
48
}
49
}
50
54
-const CONFIG_CHANGE_EVENT_PREFIX = 'new.'
51
+const CONFIG_CHANGE_EVENT_PREFIX = 'config.'
52
export const currentVersion = new Version(VERSION)
53
const configVersion = defineConfig('version', VERSION, v => new Version(v))
54
@@ -70,15 +67,12 @@ export function defineConfig<T, CT=unknown>(k: string, defaultValue: T, compiler
67
sub(cb: Subscriber<T>) {
68
if (started) // initial event already passed, we'll make the first call
69
cb(getConfig(k), { k, was: defaultValue, defaultValue, version: configVersion.compiled() })
73
- const eventName = CONFIG_CHANGE_EVENT_PREFIX + k
74
- return onOff(cfgEvents, {
75
- [eventName](v, was, version) {
76
- if (stack.includes(cb)) return // avoid infinite loop in case a subscriber changes the value
77
- stack.push(cb)
78
- try { return cb(v, { k, was, version, defaultValue }) }
79
- finally { stack.pop() }
80
- }
81
- })
70
+ return events.on(CONFIG_CHANGE_EVENT_PREFIX + k, (v, was, version) => {
71
+ if (stack.includes(cb)) return // avoid infinite loop in case a subscriber changes the value
72
+ stack.push(cb)
73
+ try { return cb(v, { k, was, version, defaultValue }) }
74
+ finally { stack.pop() }
75
+ }, { warnAfter: 1000 }) // e.g. each plugin watch enable_plugins
76
},
77
set(v: T | Updater) {
78
if (typeof v === 'function')
@@ -161,7 +155,7 @@ function setConfig1(k: string, newV: unknown, saveChanges=true, valueVersion?: V
155
if (started && same(newV, state[k])) return // no change
156
const was = getConfig(k) // include cloned default, if necessary
157
state[k] = newV
164
- cfgEvents.emit(CONFIG_CHANGE_EVENT_PREFIX + k, getConfig(k), was, valueVersion)
158
+ events.emit(CONFIG_CHANGE_EVENT_PREFIX + k, getConfig(k), was, valueVersion)
159
if (saveChanges)
160
saveConfigAsap()
161
src/ddns.ts
+6
-8
@@ -6,7 +6,6 @@ import { isIPv4 } from 'node:net'
6
import { isIPv6 } from 'net'
7
import { VERSION } from './const'
8
import events from './events'
9
-import { once } from 'stream'
9
import { getPublicIps } from './nat'
10
11
// optionally you can append '>' and a regular expression to determine what body is considered successful
@@ -14,7 +13,6 @@ const dynamicDnsUrl = defineConfig(CFG.dynamic_dns_url, '')
13
14
let stop: Callback | undefined
15
export interface DynamicDnsResult { ts: string, error: string, url: string }
17
-let last: undefined | DynamicDnsResult
16
dynamicDnsUrl.sub(v => {
17
stop?.()
18
if (!v) return
@@ -23,7 +21,7 @@ dynamicDnsUrl.sub(v => {
21
const ips = await getPublicIps()
22
if (_.isEqual(lastIps, ips)) return
23
lastIps = ips
26
- const all = await Promise.all(v.split('\n').map(async line => {
24
+ const all: DynamicDnsResult[] = await Promise.all(v.split('\n').map(async line => {
25
const [templateUrl, re] = splitAt('>', line)
26
const url = replace(templateUrl, {
27
IPX: ips[0] || '',
@@ -37,15 +35,15 @@ dynamicDnsUrl.sub(v => {
35
}, (err: any) => err.code || err.message || String(err) )
36
return { ts: new Date().toJSON(), error, url }
37
}))
40
- last = _.find(all, 'error') || all[0] // the system is designed for just one result, and we give precedence to errors
41
- events.emit('dynamicDnsError', last)
42
- console.log('dynamic dns update', last?.error || 'ok')
38
+ const best = _.find(all, 'error') || all[0] // the system is designed for just one result, and we give precedence to errors
39
+ events.emit('dynamicDnsError', best)
40
+ console.log('dynamic dns update', best?.error || 'ok')
41
})
42
})
43
44
export async function* get_dynamic_dns_error() {
45
while (1) {
48
- yield last
49
- await once(events, 'dynamicDnsError')
46
+ const res = await events.once('dynamicDnsError')
47
+ yield res[0]
48
}
49
}
\ No newline at end of file
src/events.ts
+51
-4
@@ -1,8 +1,55 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import EventEmitter from 'events'
3
+type Listener = (...args: any[]) => unknown
4
+type Listeners = Set<Listener>
5
+export class BetterEventEmitter {
6
+ protected listeners = new Map<string, Listeners>()
7
+ on(event: string | string[], listener: Listener, { warnAfter=10 }={}) {
8
+ if (typeof event === 'string')
9
+ event = [event]
10
+ for (const e of event) {
11
+ let cbs = this.listeners.get(e)
12
+ if (!cbs)
13
+ this.listeners.set(e, cbs = new Set())
14
+ cbs.add(listener)
15
+ if (cbs.size > warnAfter)
16
+ console.warn("Warning: many events listeners for ", e)
17
+ }
18
+ return () => {
19
+ for (const e of event)
20
+ this.listeners.get(e)?.delete(listener)
21
+ }
22
+ }
23
+ once(event: string, listener?: Listener) {
24
+ return new Promise<any[]>(resolve => {
25
+ const off = this.on(event, function(...args){
26
+ off()
27
+ resolve(args)
28
+ return listener?.(...arguments)
29
+ })
30
+ })
31
+ }
32
+ multi(map: { [eventName: string]: Listener }) {
33
+ const cbs = Object.entries(map).map(([name, cb]) => this.on(name.split(' '), cb))
34
+ return () => {
35
+ for (const cb of cbs) cb()
36
+ }
37
+ }
38
+ emit(event: string, ...args: any[]) {
39
+ let cbs = this.listeners.get(event)
40
+ if (!cbs?.size) return
41
+ const ret = []
42
+ for (const cb of cbs) {
43
+ const res = cb(...args)
44
+ if (res !== undefined)
45
+ ret.push(res)
46
+ }
47
+ return ret
48
+ }
49
+ emitAsync(event: string, ...args: any[]) {
50
+ return Promise.all(this.emit(event, ...args) || [])
51
+ }
52
+}
53
54
// app-wide events
6
-const ee = new EventEmitter().setMaxListeners(100)
7
-
8
-export default ee
\ No newline at end of file
55
+export default new BetterEventEmitter
\ No newline at end of file
src/github.ts
+1
-1
@@ -22,7 +22,7 @@ function downloadProgress(id: string, status: DownloadStatus) {
22
delete downloading[id]
23
else
24
downloading[id] = status
25
- events.emit('pluginDownload_'+id, status)
25
+ events.emit('pluginDownload', { id, status })
26
}
27
28
// determine default branch, possibly without consuming api quota
src/middlewares.ts
+6
-2
@@ -111,7 +111,7 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
111
function urlLogin() {
112
const { login } = ctx.query
113
if (!login) return
114
- const [u,p] = splitAt(':', String(login))
114
+ const [u, p] = splitAt(':', String(login))
115
ctx.redirect(ctx.originalUrl.slice(0, -ctx.querystring.length-1)) // redirect to hide credentials
116
return doLogin(u, p)
117
}
@@ -122,11 +122,15 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
122
}
123
124
async function doLogin(u: string, p: string) {
125
+ if (!u || u === ctx.session?.username) return // providing credentials, but not needed
126
+ await events.emitAsync('attemptingLogin', ctx)
127
const a = await srpCheck(u, p)
128
if (a) {
127
- setLoggedIn(ctx, a.username)
129
+ await setLoggedIn(ctx, a.username)
130
ctx.headers['x-username'] = a.username // give an easier way to determine if the login was successful
131
}
132
+ else if (u)
133
+ events.emit('failedLogin', ctx, { username: u })
134
return a
135
}
136
}
src/misc.ts
-13
@@ -43,19 +43,6 @@ export function pattern2filter(pattern: string){
43
!s || !pattern || matcher(basename(s))
44
}
45
46
-// install multiple handlers and returns a handy 'uninstall' function which requires no parameter. Pass a map {event:handler}
47
-export function onOff(em: EventEmitter, events: { [eventName:string]: (...args: any[]) => void }) {
48
- events = { ...events } // avoid later modifications, as we need this later for uninstallation
49
- for (const [k,cb] of Object.entries(events))
50
- for (const e of k.split(' '))
51
- em.on(e, cb)
52
- return () => {
53
- for (const [k,cb] of Object.entries(events))
54
- for (const e of k.split(' '))
55
- em.off(e, cb)
56
- }
57
-}
58
-
46
export function isLocalHost(c: Connection | Koa.Context | string) {
47
const ip = typeof c === 'string' ? c : c.socket.remoteAddress // don't use Context.ip as it is subject to proxied ips, and that's no use for localhost detection
48
return ip && isIpLocalHost(ip)