@samitouri / QOSami-HFS / commits / ca5ee667

admin: validation of net-masks

Massimo Melina committed Mar 22, 2025 at 12:43 UTC ca5ee6670117f76d5a8e8e6228466ef40a5ab023
7 files changed +59 -43
admin/src/AccountForm.ts
+1 -2
@@ -99,8 +99,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
99 + (!isGroup ? '' : ". A group can inherit from another group")
100 + (belongsOptions.length ? '' : ". Now disabled because there are no groups to select, create one first.")
101 },
102 - { k: 'allow_net', comp: NetmaskField, label: "Allowed network address", helperText: h(WildcardsSupported), sm: 6,
103 - placeholder: "Allow from any address" },
102 + { k: 'allow_net', comp: NetmaskField, label: "Allowed network address", sm: 6, placeholder: "Allow from any address" },
103 { k: 'expire', label: "Expiration", xs: true, comp: DateTimeField, toField: x => x && new Date(x),
104 helperText: "When expired, login won't be allowed" },
105 { k: 'days_to_live', xs: 12, sm: 6, comp: NumberField, disabled: expired, step: 'any', min: 1/1000, // 10 minutes
admin/src/LogsPage.ts
+1 -3
@@ -77,9 +77,7 @@ export default function LogsPage() {
77 { k: CFG.log_rotation, comp: SelectField, sm: 6, options: [{ value:'', label:"disabled" }, 'daily', 'weekly', 'monthly' ],
78 helperText: wikiLink('Logs#rotation', "To keep log-files smaller"),
79 },
80 - { k: CFG.dont_log_net, comp: NetmaskField, label: "Don't log address", sm: 6, placeholder: "no exception",
81 - helperText: h(WildcardsSupported)
82 - },
80 + { k: CFG.dont_log_net, comp: NetmaskField, label: "Don't log address", sm: 6, placeholder: "no exception" },
81 { k: CFG.log_gui, sm: 6, comp: BoolField, label: "Log interface loading", helperText: "Some requests are necessary to load the interface" },
82 { k: CFG.log_api, sm: 6, comp: BoolField, label: "Log API requests", helperText: "Requests for commands" },
83 { k: CFG.log_ua, sm: 6, comp: BoolField, label: "Log User-Agent", helperText: "Contains browser and possibly OS information. Can double the size of your logs on disk." },
admin/src/OptionsPage.ts
+3 -3
@@ -1,7 +1,7 @@
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 { Box, Button, Divider, FormHelperText } from '@mui/material';
4 -import { createElement as h, Fragment, useEffect, useRef } from 'react';
4 +import { createElement as h, useEffect, useRef } from 'react';
5 import { apiCall, useApiEx } from './api'
6 import { state, useSnapState } from './state'
7 import { Link as RouterLink } from 'react-router-dom'
@@ -148,7 +148,7 @@ export default function OptionsPage() {
148 { k : CFG.max_downloads_per_account, ...maxDownloadsDefaults, label: "Max downloads per-account", helperText: "Overrides other limits" },
149
150 { k: 'admin_net', comp: NetmaskField, label: "Admin-panel accessible from", placeholder: "any address",
151 - helperText: h(Fragment, {}, "IP address of browser machine. ", h(WildcardsSupported))
151 + helperText: "IP address of browser machine"
152 },
153 { k: 'localhost_admin', comp: BoolField, label: "Unprotected Admin-panel on localhost",
154 getError: x => !x && admins?.length===0 && "First create at least one admin account",
@@ -169,7 +169,7 @@ export default function OptionsPage() {
169 fields: [
170 { k: 'ip', label: "Blocked IP", sm: 12, required: true, wrap: true, $width: 2, comp: NetmaskField,
171 $column: { mergeRender: { comment: {}, expire: {} } },
172 - helperText: h(Flex, { component: 'span' }, h(WildcardsSupported), "Be careful to not kick yourself out, by blocking also your IP."),
172 + helperText: "Be careful to not kick yourself out, by blocking also your IP",
173 },
174 { k: 'expire', $type: 'dateTime', minDate: new Date(), sm: 6, $hideUnder: 'sm',
175 helperText: "Leave empty for no expiration" },
admin/src/mui.ts
+7 -1
@@ -277,9 +277,15 @@ export function useToggleButton(onTitle: string, offTitle: undefined | string, i
277 return [state, el, setState] as const
278 }
279
280 -export function NetmaskField(props: StringFieldProps) {
280 +export function NetmaskField({ setApi, helperText, ...props }: StringFieldProps) {
281 const warned = useRef(false)
282 + setApi?.({
283 + getError() {
284 + return props.value && apiCall('validate_net_mask', { mask: props.value }).then(x => !x.result && "Invalid mask")
285 + }
286 + })
287 return h(StringField, {
288 + helperText: h('span', {}, helperText, helperText && ' – ', wikiLink('Wildcards#network-masks', "Wildcards supported")),
289 ...props,
290 onTyping(v) {
291 if (!warned.current && v?.includes('127.0.0.1') && !v.includes('::1')) {
mui-grid-form/index.ts
+7 -4
@@ -159,10 +159,13 @@ export function Form<Values extends Dict>({
159 field.helperText = h(Fragment, {}, ...field.helperText)
160 if (errMsg) // special rendering when we have both error and helperText. "hr" would be nice but issues a warning because contained in a <p>
161 field.helperText = !field.helperText ? errMsg
162 - : h(Fragment, {},
163 - h('span', { style: { borderBottom: '1px solid' } }, errMsg),
164 - h(Box, { color: 'text.primary', component: 'span', /*avoid console warning*/ display: 'block' },
165 - field.helperText),
162 + : h(Box, { color: 'text.primary', component: 'span' },
163 + h(Box, {
164 + color: 'error.main',
165 + style: { borderBottom: '1px solid' },
166 + component: 'span', display: 'block' // avoid console warning, but keep it on separate line
167 + }, errMsg),
168 + field.helperText,
169 )
170 if (field.label === undefined)
171 field.label = labelFromKey(k)
src/adminApis.ts
+6 -1
@@ -16,7 +16,7 @@ import netApis from './api.net'
16 import logApis from './api.log'
17 import certApis from './api.cert'
18 import { getConnections } from './connections'
19 -import { apiAssertTypes, debounceAsync, isLocalHost, makeNetMatcher, typedEntries, waitFor } from './misc'
19 +import { apiAssertTypes, debounceAsync, isLocalHost, makeNetMatcher, try_, typedEntries, waitFor } from './misc'
20 import { accountCanLoginAdmin, accounts } from './perm'
21 import Koa from 'koa'
22 import { cloudflareDetected, getProxyDetected } from './middlewares'
@@ -168,6 +168,11 @@ export const adminApis = {
168 return { country: await ip2country(ip) }
169 },
170
171 + validate_net_mask({ mask }) {
172 + apiAssertTypes({ string: { mask } })
173 + return { result: Boolean(try_(() => makeNetMatcher(mask))) }
174 + },
175 +
176 } satisfies ApiHandlers
177
178 for (const [k, was] of typedEntries(adminApis))
src/misc.ts
+34 -29
@@ -39,38 +39,43 @@ export function makeNetMatcher(mask: string, emptyMaskReturns=false) {
39 return () => emptyMaskReturns
40 mask = mask.replaceAll(' ','')
41 mask = mask.replace('localhost', '::1|127.0.0.1')
42 - if (!/\/|-(?![^\[]*\])/.test(mask)) // when no CIDR and no ranges are used, then we use standard matcher, otherwise BlockList. For "-" we must skip those inside []
43 - return makeMatcher(mask)
44 - const all = mask.split('|')
45 - const neg = all[0]?.[0] === '!'
46 - if (neg)
47 - all[0] = all[0]!.slice(1)
48 - const bl = new BlockList()
49 - for (const x of all) {
50 - const m = /^([.:\da-f]+)(?:\/(\d+)|-(.+)|)$/i.exec(x) // parse cidr or range
51 - if (!m) { // we don't support wildcards in this case
52 - console.warn("error in network mask", x)
53 - continue
42 + try {
43 + if (!/\/|-(?![^\[]*\])/.test(mask)) { // when no CIDR and no ranges are used, then we use standard matcher, otherwise BlockList. For "-" we must skip those inside []
44 + if (/[^.:\da-fA-F*?|()!]/.test(mask))
45 + throw mask
46 + return makeMatcher(mask)
47 }
55 - const address = try_(() => parseAddress(m[1]!),
56 - () => console.error("invalid address " + m[1]))
57 - if (!address) continue
58 - if (m[2])
59 - try { bl.addSubnet(address, Number(m[2])) }
60 - catch { console.error("invalid net mask " + x) }
61 - else if (m[3])
62 - try { bl.addRange(address, parseAddress(m[2]!)) }
63 - catch { console.error("invalid address " + m[2]) }
64 - else
65 - bl.addAddress(address)
66 - }
67 - return (ip: string) => {
68 - try { return neg !== bl.check(parseAddress(ip)) }
69 - catch {
70 - console.error("invalid address ", ip)
71 - return false
48 + const all = mask.split('|')
49 + const neg = all[0]?.[0] === '!'
50 + if (neg)
51 + all[0] = all[0]!.slice(1)
52 + const bl = new BlockList()
53 + for (const x of all) {
54 + const m = /^([.:\da-f]+)(?:\/(\d+)|-([.:\da-f]+)|)$/i.exec(x) // parse cidr or range
55 + if (!m) throw x // we don't support wildcards in this case
56 + const address = try_(() => parseAddress(m[1]!),
57 + () => { throw m[1] })
58 + if (!address) continue
59 + if (m[2])
60 + try { bl.addSubnet(address, Number(m[2])) }
61 + catch { throw x }
62 + else if (m[3])
63 + try { bl.addRange(address, parseAddress(m[2]!)) }
64 + catch { throw m[2] }
65 + else
66 + bl.addAddress(address)
67 + }
68 + return (ip: string) => {
69 + try { return neg !== bl.check(parseAddress(ip)) }
70 + catch {
71 + console.error("invalid address ", ip)
72 + return false
73 + }
74 }
75 }
76 + catch(e: any) {
77 + throw "error in net-mask: " + e
78 + }
79 }
80
81 // can throw ERR_INVALID_ADDRESS