fix: apply security and correctness review findings across both stacks
Legacy panel: - upstream body reads now carry their own deadline and a 10 MB byte budget; a stalled modem can no longer hang /api/status fan-out forever nor OOM the proxy (request headers alone had the timeout, bodies had none) - add X-Frame-Options DENY / CSP frame-ancestors none / nosniff; the panel (delete-instance and confirmed-write dialogs) is no longer clickjackable - only send a JSON content-type when the API console request has a body, so payload-less dangerous writes stop failing with 400 and burning the one-use confirmation token - register a form-urlencoded parser (the proxy branch was unreachable) and drop the multipart parser that buffered up to 60 MB before rejecting; bodyLimit drops to 2 MB; framework-level 415 keeps the stable error body - remove /api/sms/send from readable paths: GET bypassed the write confirmation for a send endpoint - /api/instances/:id/login maps upstream failures to a stable 502 instead of leaking raw error text - guard MULTI_SIMADMIN_TIMEOUT_MS parsing (NaN aborted every request); prune dead code (buildClients, cookie expando no-op) Control plane: - deleting a notification channel detaches it from rules instead of leaving dangling ids that made every referencing rule unreadable and silently dropped future notifications; rule reads tolerate unknown ids - startup sweep resets notification_queue rows stranded in 'sending' by a crash (mirrors the sms outbox sweep); terminal outbox rows are pruned on the retention timer - /api/v1/metrics no longer emits operator-assigned node names on the session-free scrape; login limiter map is bounded and pruned; Secure cookie honors the gateway-declared x-forwarded-proto - webhook delivery sets redirect: manual (signed payloads are not replayed) - SMTP envelope sender is validated against CR/LF smuggling - scheduled reboots with delaySeconds != 3 fail fast at the dispatcher with a clear reason instead of burning every retry; contract narrowed to the pinned baseline
This commit is contained in:
+19
-3
@@ -24,7 +24,7 @@ const CATALOG = [
|
||||
|
||||
export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100, proxyPolicy = defaultProxyPolicy, confirmationOptions = {} } = {}) {
|
||||
if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required')
|
||||
const app = Fastify({ logger, bodyLimit: 60 * 1024 * 1024 })
|
||||
const app = Fastify({ logger, bodyLimit: 2 * 1024 * 1024 })
|
||||
const parseAuthority = raw => {
|
||||
const value = String(raw || '').trim().toLowerCase()
|
||||
if (!value || /[\s/@]/.test(value)) return null
|
||||
@@ -46,6 +46,11 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
|
||||
const configuredRawHost = String(configStore.snapshot.server.host).replace(/^\[|\]$/g, '').toLowerCase()
|
||||
const configuredHost = isIP(configuredRawHost) === 6 ? '::1' : configuredRawHost
|
||||
const configuredPort = String(configStore.snapshot.server.port)
|
||||
app.addHook('onRequest', async (_request, reply) => {
|
||||
reply.header('x-frame-options', 'DENY')
|
||||
reply.header('content-security-policy', "frame-ancestors 'none'")
|
||||
reply.header('x-content-type-options', 'nosniff')
|
||||
})
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
const authority=loopbackAuthority(request.headers.host)
|
||||
const injectDefault = request.headers.host === 'localhost:80' && request.raw.socket?.localPort == null
|
||||
@@ -58,7 +63,14 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
|
||||
}
|
||||
})
|
||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
|
||||
app.addContentTypeParser(/^multipart\//, { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
|
||||
app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => done(null, body))
|
||||
// Unparsable content types (e.g. multipart) must fail with the same stable body
|
||||
// the proxy policy would have produced, without buffering the payload first.
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
if (error?.code === 'FST_ERR_CTP_INVALID_MEDIA_TYPE') return reply.code(415).send({ error: 'unsupported proxy content type' })
|
||||
if (error?.code === 'FST_ERR_CTP_EMPTY_JSON_BODY') return reply.code(400).send({ error: 'empty JSON body' })
|
||||
reply.send(error)
|
||||
})
|
||||
if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' })
|
||||
const findClient = (id, reply) => {
|
||||
const client = clientRegistry.get(id)
|
||||
@@ -101,7 +113,11 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
|
||||
app.post('/api/instances/:id/login', async (request, reply) => {
|
||||
const client = findClient(request.params.id, reply); if (!client) return
|
||||
const supplied = Object.hasOwn(request.body || {}, 'password')
|
||||
return { ...(await client.ensureAuthenticated(supplied ? { credential: request.body.password } : {})), hasSavedPassword: Boolean(configStore.snapshot.instances.find(item => item.id === request.params.id)?.auth?.password) }
|
||||
try {
|
||||
return { ...(await client.ensureAuthenticated(supplied ? { credential: request.body.password } : {})), hasSavedPassword: Boolean(configStore.snapshot.instances.find(item => item.id === request.params.id)?.auth?.password) }
|
||||
} catch (error) {
|
||||
return reply.code(502).send({ error: 'upstream request failed' })
|
||||
}
|
||||
})
|
||||
app.post('/api/instances/:id/logout', async (request, reply) => {
|
||||
const client = findClient(request.params.id, reply); if (!client) return
|
||||
|
||||
Reference in New Issue
Block a user