admin/options: admin-panel accessible from

Massimo Melina committed Mar 23, 2023 at 11:19 UTC fb95c8ca28fefe6b3f0aab67903d082f01dc306d
7 files changed +39 -14
admin/src/OptionsPage.ts
+6 -1
@@ -39,6 +39,8 @@ export const logLabels = {
39 error_log: "Access error log file"
40 }
41
42 +const NetmaskField = StringField
43 +
44 export default function OptionsPage() {
45 const { data, reload: reloadConfig, element } = useApiEx('get_config', { omit: ['vfs'] })
46 const snap = useSnapState()
@@ -129,6 +131,9 @@ export default function OptionsPage() {
131 helperText: "Leave empty to never delete" },
132 { k: 'min_available_mb', comp: NumberField, md: 3, min : 0, unit: "MBytes", placeholder: "None",
133 label: "Min. available disk space", helperText: "Reject uploads that don't comply" },
134 + { k: 'admin_net', comp: NetmaskField, label: "Admin-panel accessible from", placeholder: "any address",
135 + helperText: "IP address of browser machine. Wildcards supported."
136 + },
137 { k: 'zip_calculate_size_for_seconds', comp: NumberField, label: "Calculate ZIP size for", unit: "seconds",
138 helperText: "If time is not enough, the browser will not show download percentage" },
139 { k: 'mime', comp: StringStringField,
@@ -260,7 +265,7 @@ function AllowedReferer({ label, value, onChange, error }: FieldProps<string>) {
265 placeholder: 'example.com',
266 onChange,
267 error,
263 - helperText: "Masks supported"
268 + helperText: "Wildcards supported"
269 })
270 )
271 }
plugins/vhosting/plugin.js
+1 -1
@@ -7,7 +7,7 @@ exports.config = {
7 label: '',
8 type: 'array',
9 fields: {
10 - host: { label: "Domain", helperText: "Masks supported: domain.*|other.*" },
10 + host: { label: "Domain", helperText: "Wildcards supported: domain.*|other.*" },
11 root: { helperText: "Root path in VFS" },
12 }
13 },
src/adminApis.ts
+15 -5
@@ -10,9 +10,7 @@ import {
10 HFS_STARTED,
11 IS_WINDOWS,
12 VERSION,
13 - HTTP_UNAUTHORIZED,
14 - HTTP_NOT_FOUND,
15 - HTTP_BAD_REQUEST, HTTP_SERVER_ERROR
13 + HTTP_UNAUTHORIZED, HTTP_NOT_FOUND, HTTP_BAD_REQUEST, HTTP_SERVER_ERROR, HTTP_FORBIDDEN
14 } from './const'
15 import vfsApis from './api.vfs'
16 import accountsApis from './api.accounts'
@@ -20,9 +18,9 @@ import pluginsApis from './api.plugins'
18 import monitorApis from './api.monitor'
19 import langApis from './api.lang'
20 import { getConnections } from './connections'
23 -import { debounceAsync, isLocalHost, onOff, waitFor } from './misc'
21 +import { debounceAsync, isLocalHost, matchesNet, onOff, waitFor } from './misc'
22 import events from './events'
25 -import { anyAccountCanLoginAdmin, getFromAccount } from './perm'
23 +import { accountCanLoginAdmin, accountsConfig, getFromAccount } from './perm'
24 import Koa from 'koa'
25 import { getProxyDetected } from './middlewares'
26 import { writeFile } from 'fs/promises'
@@ -32,6 +30,7 @@ import { loggers } from './log'
30 import { execFile } from 'child_process'
31 import { promisify } from 'util'
32 import { customHtmlSections, customHtmlState, saveCustomHtml } from './customHtml'
33 +import _ from 'lodash'
34
35 export const adminApis: ApiHandlers = {
36
@@ -150,6 +149,8 @@ export const adminApis: ApiHandlers = {
149
150 for (const [k, was] of Object.entries(adminApis))
151 adminApis[k] = (params, ctx) => {
152 + if (!allowAdmin(ctx))
153 + return new ApiError(HTTP_FORBIDDEN)
154 if (ctxAdminAccess(ctx))
155 return was(params, ctx)
156 const props = { any: anyAccountCanLoginAdmin() }
@@ -159,6 +160,7 @@ for (const [k, was] of Object.entries(adminApis))
160 }
161
162 export const localhostAdmin = defineConfig('localhost_admin', true)
163 +export const adminNet = defineConfig('admin_net', '')
164 export const favicon = defineConfig<string>('favicon')
165 export const title = defineConfig('title', "File server")
166
@@ -173,3 +175,11 @@ const frpDebounced = debounceAsync(async () => {
175 const { stdout } = await promisify(execFile)('tasklist', ['/fi','imagename eq frpc.exe','/nh'])
176 return stdout.includes('frpc')
177 })
178 +
179 +export function anyAccountCanLoginAdmin() {
180 + return Boolean(_.find(accountsConfig.get(), accountCanLoginAdmin))
181 +}
182 +
183 +export function allowAdmin(ctx: Koa.Context) {
184 + return matchesNet(ctx, adminNet.get(), true)
185 +}
\ No newline at end of file
src/listen.ts
+1 -1
@@ -11,7 +11,7 @@ import open from 'open'
11 import { debounceAsync, onlyTruthy, wait } from './misc'
12 import { ADMIN_URI, DEV } from './const'
13 import findProcess from 'find-process'
14 -import { anyAccountCanLoginAdmin } from './perm'
14 +import { anyAccountCanLoginAdmin } from './adminApis'
15 import _ from 'lodash'
16
17 interface ServerExtra { name: string, error?: string, busy?: Promise<string> }
src/middlewares.ts
+3 -2
@@ -28,7 +28,7 @@ import { basename, dirname } from 'path'
28 import { pipeline } from 'stream/promises'
29 import formidable from 'formidable'
30 import { uploadWriter } from './upload'
31 -import { favicon } from './adminApis'
31 +import { allowAdmin, favicon } from './adminApis'
32
33 export const gzipper = compress({
34 threshold: 2048,
@@ -86,7 +86,8 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
86 if (path.length === ADMIN_URI.length - 1 && ADMIN_URI.startsWith(path))
87 return ctx.redirect(ctx.state.revProxyPath + ADMIN_URI)
88 if (path.startsWith(ADMIN_URI))
89 - return serveAdminPrefixed(ctx,next)
89 + return allowAdmin(ctx) ? serveAdminPrefixed(ctx,next)
90 + : (ctx.status = HTTP_FORBIDDEN)
91 if (ctx.method === 'PUT') { // curl -T file url/
92 const decPath = decodeURI(path)
93 let rest = basename(decPath)
src/misc.ts
+13
@@ -11,6 +11,7 @@ export * from './util-generators'
11 export * from './util-files'
12 import debounceAsync from './debounceAsync'
13 import { Readable } from 'stream'
14 +import { isMatch } from 'micromatch'
15 export { debounceAsync }
16
17 export type Callback<IN=void, OUT=void> = (x:IN) => OUT
@@ -171,6 +172,18 @@ export function isLocalHost(c: Connection | Koa.Context) {
172 return ip && (ip === '::1' || ip.endsWith('127.0.0.1'))
173 }
174
175 +export function matchesNet(ip: Koa.Context | string, mask: string, emptyReturns=false) {
176 + if (!mask)
177 + return emptyReturns
178 + if (typeof ip !== 'string')
179 + ip = ip.ip
180 + return matches(ip, mask)
181 +}
182 +
183 +function matches(s: string, mask: string) {
184 + return isMatch(s, '(' + mask + ')') // adding () will allow us to use the pipe at root level
185 +}
186 +
187 export function same(a: any, b: any) {
188 try {
189 assert.deepStrictEqual(a, b)
src/perm.ts
-4
@@ -188,7 +188,3 @@ export function accountCanLogin(account: Account) {
188 export function accountCanLoginAdmin(account: Account) {
189 return accountCanLogin(account) && Boolean(getFromAccount(account, a => a.admin))
190 }
191 -
192 -export function anyAccountCanLoginAdmin() {
193 - return Boolean(_.find(accountsConfig.get(), accountCanLoginAdmin))
194 -}