admin APIs validation
Massimo Melina committed
Jan 18, 2026 at 10:10 UTC
dfe0473eb69131d7ef7a83d9f3babeff030cfe3f
12 files changed
+63
-21
admin/src/LogsPage.ts
+1
-1
@@ -130,7 +130,7 @@ export function LogFile({ file, footerSide, hidden, limit, filter, ...rest }: Lo
130
const [firstSight, setFirstSight] = useState(!hidden)
131
useEffect(() => setFirstSight(x => x || !hidden), [hidden])
132
const hasFile = LOGS_ON_FILE.includes(file)
133
- useApi(firstSight && hasFile && 'get_log_file', { file, range: limited || !skipped ? -MAX : `0-${skipped}` }, {
133
+ useApi(firstSight && hasFile && 'get_log_file', { file, range: limited || !skipped ? String(-MAX) : `0-${skipped}` }, {
134
skipParse: true, skipLog: true,
135
onResponse(res, body) {
136
const lines = body.split('\n')
src/adminApis.ts
+16
-5
@@ -73,10 +73,16 @@ export const adminApis = {
73
customHtml: customHtml.getText(),
74
}
75
},
76
- set_config_text: ({ text }) => configFile.save(text, { reparse: true }),
77
- update: ({ tag }) => update(tag).catch(e => {
78
- throw e.cause?.statusCode ? new ApiError(e.cause?.statusCode) : e
79
- }),
76
+ set_config_text: ({ text }) => {
77
+ apiAssertTypes({ string: { text } })
78
+ return configFile.save(text, { reparse: true })
79
+ },
80
+ update: ({ tag }) => {
81
+ apiAssertTypes({ string_undefined: { tag } })
82
+ return update(tag).catch(e => {
83
+ throw e.cause?.statusCode ? new ApiError(e.cause?.statusCode) : e
84
+ })
85
+ },
86
async check_update() {
87
return { options: await getUpdates() }
88
},
@@ -91,6 +97,10 @@ export const adminApis = {
97
},
98
99
async ip_country({ ips }) {
100
+ apiAssertTypes({
101
+ array: { ips },
102
+ string: { ips0: ips[0] }
103
+ })
104
const res = await Promise.allSettled(ips.map(ip2country))
105
return {
106
codes: res.map(x => x.status === 'rejected' || x.value === '-' ? '' : x.value)
@@ -115,6 +125,7 @@ export const adminApis = {
125
},
126
127
async set_custom_html({ sections }) {
128
+ apiAssertTypes({ object: { sections } })
129
await saveCustomHtml(sections)
130
return {}
131
},
@@ -216,4 +227,4 @@ export function anyAccountCanLoginAdmin() {
227
228
export function preventAdminAccess(ctx: Koa.Context) {
229
return !isLocalHost(ctx) && !adminNet.compiled()(ctx.ip)
219
-}
\ No newline at end of file
230
+}
src/api.accounts.ts
+1
@@ -44,6 +44,7 @@ export default {
44
},
45
46
get_account({ username }, ctx) {
47
+ apiAssertTypes({ string: { username } })
48
return prepareAccount(getAccount(username || getCurrentUsername(ctx)))
49
|| new ApiError(HTTP_NOT_FOUND)
50
},
src/api.auth.ts
+2
@@ -14,6 +14,7 @@ import { failAllowNet, sessionDuration } from './middlewares'
14
import { clearTextLogin, getCurrentUsername, setLoggedIn, srpServerStep1 } from './auth'
15
import { defineConfig } from './config'
16
import events from './events'
17
+import { apiAssertTypes } from './misc'
18
19
const ongoingLogins:Record<string,SRPServerSessionStep1> = {} // store data that doesn't fit session object
20
const keepSessionAlive = defineConfig('keep_session_alive', true)
@@ -39,6 +40,7 @@ export const login: ApiHandler = async ({ username, password }, ctx) => {
40
}
41
42
export const loginSrp1: ApiHandler = async ({ username }, ctx) => {
43
+ apiAssertTypes({ string: { username } })
44
if (!username)
45
return new ApiError(HTTP_BAD_REQUEST)
46
const account = getAccount(username)
src/api.get_file_list.ts
+3
-2
@@ -6,7 +6,7 @@ import {
6
} from './vfs'
7
import { ApiError, ApiHandler } from './apiMiddleware'
8
import { mapPlugins } from './plugins'
9
-import { asyncGeneratorToArray, pattern2filter, statWithTimeout, WHO_NO_ONE } from './misc'
9
+import { apiAssertTypes, asyncGeneratorToArray, pattern2filter, statWithTimeout, WHO_NO_ONE } from './misc'
10
import { HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND } from './const'
11
import Koa from 'koa'
12
import { getCommentFor, areCommentsEnabled } from './comments'
@@ -30,6 +30,7 @@ export function paramsToFilter({ search, wild, searchComment, fileMask }: any) {
30
}
31
32
export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onlyFolders, onlyFiles, admin, ...rest }, ctx) => {
33
+ apiAssertTypes({ string: { uri }})
34
const node = await urlToNode(uri, ctx)
35
const list = ctx.get('accept') === 'text/event-stream' ? new SendListReadable() : undefined
36
if (!node)
@@ -157,4 +158,4 @@ declare module "koa" {
158
interface DefaultState {
159
browsing?: string // for admin/monitoring
160
}
160
-}
\ No newline at end of file
161
+}
src/api.lang.ts
+2
-2
@@ -5,7 +5,7 @@ import _ from 'lodash'
5
import glob from 'fast-glob'
6
import { readFile, rm, writeFile } from 'fs/promises'
7
import { HTTP_BAD_REQUEST, HTTP_NOT_ACCEPTABLE, HTTP_SERVER_ERROR } from './const'
8
-import { tryJson } from './misc'
8
+import { apiAssertTypes, tryJson } from './misc'
9
import { code2file, file2code } from './lang'
10
import EMBEDDED_TRANSLATIONS from './langs/embedded'
11
import { SendListReadable } from './SendList'
@@ -43,6 +43,7 @@ const apis: ApiHandlers = {
43
},
44
45
async add_langs({ langs }) {
46
+ apiAssertTypes({ object: { langs } })
47
for (let [code, content] of Object.entries(langs)) {
48
code = file2code(code)
49
validateCode(code)
@@ -63,4 +64,3 @@ function validateCode(code: string) {
64
if (!/^(\w\w)(-\w\w)*$/.test(code))
65
throw new ApiError(HTTP_BAD_REQUEST, 'bad code/filename')
66
}
66
-
src/api.log.ts
+5
-3
@@ -17,6 +17,7 @@ export default {
17
},
18
19
async get_log_file({ file = 'log', range = '' }, ctx) { // this is limited to logs on file, and serves the file instead of a list of records
20
+ apiAssertTypes({ string: { file, range } })
21
const log = _.find(loggers, { name: file })
22
if (!log)
23
throw HTTP_NOT_FOUND
@@ -32,7 +33,8 @@ export default {
33
},
34
35
get_log({ file = 'log' }, ctx) {
35
- const files = file.split('|') // potentially more then one
36
+ apiAssertTypes({ string: { file } })
37
+ const files = file.split('|') // potentially more than one
38
return new SendListReadable({
39
bufferTime: 10,
40
async doAtStart(list) {
@@ -59,7 +61,7 @@ export default {
61
if (_.some(files, x => !_.find(loggers, { name: x })) )
62
return list.error(HTTP_NOT_FOUND, true)
63
list.ready()
62
- // unsubscribe when connection is interrupted
64
+ // unsubscribe when the connection is interrupted
65
ctx.res.once('close', events.on(files, x =>
66
list.add(Object.assign(_.pick(x.ctx, ['ip', 'method','status']), x, { ctx: undefined }))))
67
}
@@ -92,4 +94,4 @@ export default {
94
return ips.clear()
95
},
96
95
-} satisfies ApiHandlers
\ No newline at end of file
97
+} satisfies ApiHandlers
src/api.monitor.ts
+5
@@ -13,6 +13,11 @@ import { storedMap } from './persistence'
13
export default {
14
15
async disconnect({ ip, port, allButLocalhost }) {
16
+ apiAssertTypes({
17
+ string_undefined: { ip },
18
+ number_undefined: { port },
19
+ boolean_undefined: { allButLocalhost },
20
+ })
21
const match = allButLocalhost ? ((x: any) => !isLocalHost(x.ip))
22
: _.matches({ ip, port })
23
const found = getConnections().filter(c => match(getConnAddress(c)))
src/api.net.ts
+9
-2
@@ -1,7 +1,9 @@
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 { ApiError, ApiHandlers } from './apiMiddleware'
4
-import { HTTP_FAILED_DEPENDENCY, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE, HTTP_PRECONDITION_FAILED } from './const'
4
+import {
5
+ HTTP_BAD_REQUEST, HTTP_FAILED_DEPENDENCY, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE, HTTP_PRECONDITION_FAILED
6
+} from './const'
7
import _ from 'lodash'
8
import { getCertObject } from './listen'
9
import { getProjectInfo } from './github'
@@ -43,6 +45,7 @@ export default {
45
},
46
47
async map_port({ external, internal }) {
48
+ apiAssertTypes({ number_undefined: { external, internal } })
49
const { upnp, externalPort, internalPort } = await getNatInfo()
50
if (!upnp)
51
return new ApiError(HTTP_SERVICE_UNAVAILABLE, "upnp failed")
@@ -60,6 +63,7 @@ export default {
63
},
64
65
async self_check({ url }) {
66
+ apiAssertTypes({ string_undefined: { url } })
67
if (url)
68
return await selfCheck(url)
69
|| new ApiError(HTTP_SERVICE_UNAVAILABLE)
@@ -77,6 +81,9 @@ export default {
81
},
82
83
async make_cert({domain, email, altNames}) {
84
+ apiAssertTypes({ string: { domain }, string_undefined: { email }, array_undefined: { altNames } })
85
+ if (altNames?.some((name: unknown) => typeof name !== 'string'))
86
+ return new ApiError(HTTP_BAD_REQUEST, 'bad altNames')
87
await makeCert(domain, email, altNames).catch(e => {
88
throw new ApiError(HTTP_SERVER_ERROR, e.message || String(e))
89
})
@@ -86,4 +93,4 @@ export default {
93
get_cert() {
94
return getCertObject() || { none: true }
95
}
89
-} satisfies ApiHandlers
\ No newline at end of file
96
+} satisfies ApiHandlers
src/api.plugins.ts
+17
-4
@@ -7,13 +7,13 @@ import {
7
} from './plugins'
8
import _ from 'lodash'
9
import assert from 'assert'
10
-import { HTTP_CONFLICT, HTTP_PRECONDITION_FAILED, newObj, waitFor } from './misc'
10
+import { apiAssertTypes, HTTP_CONFLICT, HTTP_PRECONDITION_FAILED, newObj, waitFor } from './misc'
11
import { ApiError, ApiHandlers } from './apiMiddleware'
12
import { rm } from 'fs/promises'
13
import {
14
downloadPlugin, getFolder2repo, readOnlineCompatiblePlugin, readOnlinePlugin, searchPlugins, downloading
15
} from './github'
16
-import { HTTP_FAILED_DEPENDENCY, HTTP_NOT_FOUND, HTTP_SERVER_ERROR } from './const'
16
+import { HTTP_BAD_REQUEST, HTTP_FAILED_DEPENDENCY, HTTP_NOT_FOUND, HTTP_SERVER_ERROR } from './const'
17
import { SendListReadable } from './SendList'
18
19
const apis: ApiHandlers = {
@@ -72,6 +72,7 @@ const apis: ApiHandlers = {
72
},
73
74
async start_plugin({ id }) {
75
+ assertPluginId(id)
76
if (isPluginRunning(id))
77
return { msg: 'already running' }
78
if (suspendPlugins.get())
@@ -81,6 +82,7 @@ const apis: ApiHandlers = {
82
},
83
84
async stop_plugin({ id }) {
85
+ assertPluginId(id)
86
if (!isPluginRunning(id))
87
return { msg: 'already stopped' }
88
await stopPlugin(id)
@@ -88,7 +90,7 @@ const apis: ApiHandlers = {
90
},
91
92
async set_plugin({ id, enabled, config }) {
91
- assert(id, 'id')
93
+ assertPluginId(id)
94
if (config)
95
setPluginConfig(id, config)
96
if (enabled !== undefined)
@@ -97,6 +99,7 @@ const apis: ApiHandlers = {
99
},
100
101
async get_plugin({ id }) {
102
+ assertPluginId(id)
103
return {
104
enabled: enablePlugins.get().includes(id),
105
config: {
@@ -107,6 +110,8 @@ const apis: ApiHandlers = {
110
},
111
112
get_online_plugins({ text }, ctx) {
113
+ if (text !== undefined && !_.isString(text))
114
+ return new ApiError(HTTP_BAD_REQUEST, 'bad text')
115
return new SendListReadable({
116
async doAtStart(list) {
117
const repos = [] as string[]
@@ -144,6 +149,7 @@ const apis: ApiHandlers = {
149
},
150
151
async download_plugin({ id, branch, stop }) {
152
+ assertPluginId(id)
153
await checkDependencies(await readOnlinePlugin(id, branch))
154
const folder = await downloadPlugin(id, { branch })
155
if (stop) // be sure this is not automatically started
@@ -153,6 +159,7 @@ const apis: ApiHandlers = {
159
},
160
161
async update_plugin({ id }) {
162
+ assertPluginId(id)
163
const found = getPluginInfo(id)
164
if (!found)
165
return new ApiError(HTTP_NOT_FOUND)
@@ -165,6 +172,7 @@ const apis: ApiHandlers = {
172
},
173
174
async uninstall_plugin({ id, deleteConfig }) {
175
+ assertPluginId(id)
176
await stopPlugin(id)
177
await rm(PLUGINS_PATH + '/' + id, { recursive: true, force: true })
178
if (deleteConfig)
@@ -173,6 +181,7 @@ const apis: ApiHandlers = {
181
},
182
183
get_plugin_log({ id }, ctx) {
184
+ assertPluginId(id)
185
const p = getPluginInfo(id)
186
if (!p)
187
return new ApiError(HTTP_NOT_FOUND)
@@ -186,6 +195,10 @@ const apis: ApiHandlers = {
195
196
export default apis
197
198
+function assertPluginId(id: unknown) {
199
+ apiAssertTypes({ string: { id } })
200
+}
201
+
202
function serialize(p: Readonly<Plugin> | InactivePlugin) {
203
let o = 'getData' in p ? Object.assign(_.pick(p, ['id','started']), p.getData())
204
: { ...p } // _.defaults mutates object, and we don't want that
@@ -201,4 +214,4 @@ export async function checkDependencies(plugin: CommonPluginInterface) {
214
const miss = await getMissingDependencies(plugin)
215
if (miss.length)
216
throw new ApiError(HTTP_FAILED_DEPENDENCY, miss)
204
-}
\ No newline at end of file
217
+}
src/frontEndApis.ts
+1
-1
@@ -41,7 +41,7 @@ export const frontEndApis: ApiHandlers = {
41
},
42
43
async get_file_details({ uris }, ctx) {
44
- if (typeof uris?.[0] !== 'string')
44
+ if (!Array.isArray(uris) || typeof uris[0] !== 'string')
45
return new ApiError(HTTP_BAD_REQUEST, 'bad uris')
46
const isAdmin = ctxAdminAccess(ctx)
47
return {
src/misc.ts
+1
-1
@@ -109,7 +109,7 @@ export function apiAssertTypes(paramsByType: { [type:string]: { [name:string]: a
109
if (!_.isPlainObject(params))
110
throw "invalid apiAssertTypes call"
111
for (const [name, val] of Object.entries(params))
112
- if (!types.split('_').some(type => type === 'array' ? Array.isArray(val) : typeof val === type))
112
+ if (!types.split('_').some(t => t === 'array' ? Array.isArray(val) : t === 'object' ? _.isPlainObject(val) : typeof val === t))
113
throw new ApiError(HTTP_BAD_REQUEST, 'bad ' + name)
114
}
115
}