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)