admin/accounts: new "Allowed network address" #864
Massimo Melina committed
Jan 12, 2025 at 19:14 UTC
e6c3c5546879d9657757d251862b164dea2af0f2
9 files changed
+37
-9
admin/src/AccountForm.ts
+4
-2
@@ -6,7 +6,7 @@ import { Alert, Box } from '@mui/material'
6
import { apiCall } from './api'
7
import { alertDialog, useDialogBarColors } from './dialog'
8
import { formatTimestamp, isEqualLax, prefix, reactJoin, useIsMobile, wantArray } from './misc'
9
-import { IconBtn, propsForModifiedValues } from './mui'
9
+import { IconBtn, NetmaskField, propsForModifiedValues, WildcardsSupported } from './mui'
10
import { Account } from './AccountsPage'
11
import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
12
import { AutoDelete, Delete } from '@mui/icons-material'
@@ -80,11 +80,13 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
80
: members.length > 0 && h(Box, {}, `${members.length} members: `,
81
reactJoin(', ', account.members?.map((u: string) => h(groups.includes(u) ? 'i' : 'span', {}, u))) ),
82
group && h(Alert, { severity: 'info' }, `To add users to this group, select the user and then click "Inherit"`),
83
- { k: 'belongs', comp: MultiSelectField, label: "Inherit from groups", options: belongsOptions,
83
+ { k: 'belongs', comp: MultiSelectField, label: "Inherit from groups", options: belongsOptions, sm: 6,
84
helperText: "Specify groups to inherit permissions from"
85
+ (!group ? '' : ". A group can inherit from another group")
86
+ (belongsOptions.length ? '' : ". Now disabled because there are no groups to select, create one first.")
87
},
88
+ { k: 'allow_net', comp: NetmaskField, label: "Allowed network address", helperText: h(WildcardsSupported), sm: 6,
89
+ placeholder: "Allow from any address" },
90
{ k: 'expire', label: "Expiration", xs: true, comp: DateTimeField, toField: x => x && new Date(x),
91
helperText: "When expired, login won't be allowed" },
92
{ k: 'days_to_live', xs: 12, sm: 6, comp: NumberField, disabled: expired, step: 'any', min: 1/1000, // 10 minutes
config.md
+1
@@ -207,6 +207,7 @@ For each account entries, this is the list of properties you can have:
207
- `admin` set `true` if you want to let this account log in to the Admin-panel. Default is `false`.
208
- `belongs` an array of usernames of other accounts from which to inherit their permissions. Default is none.
209
- `disable_password_change` set `true` if you want to forbid password change for users. Default is `false`.
210
+- `allow_net` a mask of addresses to restrict the access of the account
211
212
### Specify another file
213
src/api.auth.ts
+3
-1
@@ -5,7 +5,7 @@ import { ApiError, ApiHandler } from './apiMiddleware'
5
import { SRPServerSessionStep1 } from 'tssrp6a'
6
import { ADMIN_URI, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_FOUND } from './const'
7
import { ctxAdminAccess } from './adminApis'
8
-import { sessionDuration } from './middlewares'
8
+import { failAllowNet, sessionDuration } from './middlewares'
9
import { getCurrentUsername, setLoggedIn, srpServerStep1 } from './auth'
10
import { defineConfig } from './config'
11
import events from './events'
@@ -25,6 +25,8 @@ export const loginSrp1: ApiHandler = async ({ username }, ctx) => {
25
ctx.state.dontLog = false // log even if log_api is false
26
return new ApiError(HTTP_UNAUTHORIZED)
27
}
28
+ if (failAllowNet(ctx, account))
29
+ return new ApiError(HTTP_UNAUTHORIZED)
30
try {
31
const { srpServer, ...rest } = await srpServerStep1(account)
32
const sid = Math.random()
src/auth.ts
+1
@@ -46,6 +46,7 @@ export async function setLoggedIn(ctx: Context, username: string | false) {
46
if (username === false) {
47
events.emit('logout', ctx)
48
delete s.username
49
+ delete s.allowNet
50
return
51
}
52
const a = ctx.state.account = getAccount(username)
src/middlewares.ts
+11
-3
@@ -3,10 +3,10 @@
3
import compress from 'koa-compress'
4
import Koa from 'koa'
5
import { API_URI, DEV, HTTP_FOOL } from './const'
6
-import { CFG, DAY, dirTraversal, isLocalHost, splitAt, stream2string, tryJson } from './misc'
6
+import { CFG, DAY, dirTraversal, isLocalHost, netMatches, splitAt, stream2string, tryJson } from './misc'
7
import { Readable } from 'stream'
8
import { applyBlock } from './block'
9
-import { Account, accountCanLogin, getAccount } from './perm'
9
+import { Account, accountCanLogin, getAccount, getFromAccount } from './perm'
10
import { Connection, normalizeIp, socket2connection, updateConnectionForCtx } from './connections'
11
import { invalidateSessionBefore, setLoggedIn, srpCheck } from './auth'
12
import { constants } from 'zlib'
@@ -106,7 +106,7 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
106
// calculate these once and for all
107
ctx.state.connection = socket2connection(ctx.socket)!
108
const a = ctx.state.account = await urlLogin() || await getHttpAccount() || getAccount(ctx.session?.username, false)
109
- if (a && !accountCanLogin(a))
109
+ if (a && (!accountCanLogin(a) || failAllowNet(ctx, a))) // enforce allow_net also after login
110
ctx.state.account = undefined
111
ctx.state.revProxyPath = ctx.get('x-forwarded-prefix')
112
updateConnectionForCtx(ctx)
@@ -144,6 +144,14 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
144
}
145
}
146
147
+export function failAllowNet(ctx: Koa.Context, a: Account | undefined) {
148
+ const cached = ctx.session?.allowNet // won't reflect changes until session is terminated
149
+ const mask = cached ?? getFromAccount(a || '', a => a.allow_net)
150
+ if (!cached && mask && ctx.session?.username)
151
+ ctx.session.allowNet = mask // must be deleted on logout by setLoggedIn
152
+ return mask && !netMatches(ctx.ip, mask, true)
153
+}
154
+
155
declare module "koa" {
156
interface DefaultState {
157
params: Record<string, any>
src/misc.ts
+5
@@ -19,6 +19,7 @@ import { statusCodeForMissingPerm, VfsNode } from './vfs'
19
import events from './events'
20
import { rm } from 'fs/promises'
21
import { setCommentFor } from './comments'
22
+import _ from 'lodash'
23
24
export function pattern2filter(pattern: string){
25
const matcher = makeMatcher(pattern.includes('*') ? pattern // if you specify *, we'll respect its position
@@ -32,6 +33,10 @@ export function isLocalHost(c: Connection | Koa.Context | string) {
33
return ip && isIpLocalHost(ip)
34
}
35
36
+// this will memory-leak over mask, so be careful with what you use this
37
+export function netMatches(ip: string, mask: string, emptyMaskReturns=false) {
38
+ return _.memoize(makeNetMatcher, (a,b) => `${a}\t${b ? 1 : 0}`)(mask, emptyMaskReturns)(ip) // cache the matcher
39
+}
40
export function makeNetMatcher(mask: string, emptyMaskReturns=false) {
41
if (!mask)
42
return () => emptyMaskReturns
src/perm.ts
+2
-1
@@ -21,7 +21,8 @@ export interface Account {
21
redirect?: string
22
disabled?: boolean
23
expire?: Date
24
- days_to_live?: number
24
+ days_to_live?: number // this is not inherited, but it will affect sub-accounts via 'expire'
25
+ allow_net?: string
26
}
27
interface Accounts { [username:string]: Account }
28
tests/config.yaml
+2
-1
@@ -137,8 +137,9 @@ accounts:
137
disabled_account:
138
disabled: true
139
admins:
140
+ allow_net: ::1
141
admin: true
141
-version: 0.55.3
142
+version: 0.56.0-alpha0.1
143
max_downloads_per_account: 2
144
max_downloads: 1
145
roots:
tests/test.ts
+8
-1
@@ -25,6 +25,7 @@ const UPLOAD_RELATIVE = 'temp/gpl.png'
25
const UPLOAD_DEST = UPLOAD_ROOT + UPLOAD_RELATIVE
26
const BIG_CONTENT = _.repeat(randomId(10), 200_000) // 2MB, big enough to saturate buffers
27
const throttle = BIG_CONTENT.length /1000 /0.5 // KB, finish in 0.5s, quick but still overlapping downloads
28
+let defaultBaseUrl = BASE_URL
29
30
describe('basics', () => {
31
//before(async () => appStarted)
@@ -115,6 +116,12 @@ describe('basics', () => {
116
it('delete.need account.method', req(UPLOAD_ROOT, 401, { method: 'DELETE' }))
117
it('rename.no perm', reqApi('rename', { uri: '/for-admins', dest: 'any' }, 401))
118
it('of_disabled.cantLogin', () => login('of_disabled').then(() => { throw Error('logged in') }, () => 0))
119
+ it('allow_net.canLogin', () => login('rejetto')) // localhost is normally resolved as ::1
120
+ it('allow_net.cantLogin', () => {
121
+ defaultBaseUrl = BASE_URL.replace('localhost', '127.0.0.1')
122
+ return login('rejetto').then(() => { throw Error('logged in') }, () => 0)
123
+ .finally(() => defaultBaseUrl = BASE_URL)
124
+ })
125
})
126
127
describe('accounts', () => {
@@ -222,7 +229,7 @@ const jar = {}
229
230
function req(url: string, test:Tester, { baseUrl, throttle, ...requestOptions }: XRequestOptions & { throttle?: number, baseUrl?: string }={}) {
231
// passing 'path' keeps it as it is, avoiding internal resolving
225
- return () => httpStream((baseUrl || BASE_URL) + url, { path: url, jar, ...requestOptions }).catch(e => {
232
+ return () => httpStream((baseUrl || defaultBaseUrl) + url, { path: url, jar, ...requestOptions }).catch(e => {
233
if (e.code === 'ECONNREFUSED')
234
throw e
235
return e.cause