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 }