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:
chick
2026-09-07 01:57:47 +08:00
parent 236e78327c
commit 2977f75129
16 changed files with 168 additions and 26 deletions
+19 -3
View File
@@ -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
+40 -5
View File
@@ -1,7 +1,13 @@
export { normalizeBaseUrl, normalizeInstance } from './config/schema.js'
export const DEFAULT_TIMEOUT_MS = Number(process.env.MULTI_SIMADMIN_TIMEOUT_MS || 6000)
function parsePositiveIntEnv(value, fallback) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
export const DEFAULT_TIMEOUT_MS = parsePositiveIntEnv(process.env.MULTI_SIMADMIN_TIMEOUT_MS, 6000)
export const DEFAULT_MAX_BODY_BYTES = parsePositiveIntEnv(process.env.MULTI_SIMADMIN_MAX_BODY_BYTES, 10 * 1024 * 1024)
export function redactInstance(instance) {
return {
id: instance.id,
@@ -165,7 +171,6 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
const headers = new Headers(options.headers || {})
const cookie = jar.header()
if (cookie && !headers.has('cookie')) headers.set('cookie', cookie)
if (headers.has('cookie')) headers.cookie = headers.get('cookie')
try {
const response = await fetchImpl(url, { ...options, headers, signal: controller.signal, redirect: options.redirect || 'manual' })
jar.setFromHeaders(response.headers)
@@ -178,7 +183,7 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
async function fetchJson(endpoint, options = {}) {
const startedAt = Date.now()
const response = await request(endpoint, { method: 'GET', ...options, headers: { accept: 'application/json,text/plain,*/*', ...(options.headers || {}) } })
const text = await response.text()
const text = (await readResponseBody(response, { bodyTimeoutMs: options.timeoutMs || timeoutMs })).toString('utf8')
let data = null
try { data = text ? JSON.parse(text) : null } catch {}
return { ok: response.ok, status: response.status, latencyMs: Date.now() - startedAt, data, text: data ? undefined : text.slice(0, 1000), headers: response.headers }
@@ -212,6 +217,36 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
return { instance, jar, request, fetchJson, ensureAuthenticated, clearEphemeralSecret }
}
export function buildClients(instances, options = {}) {
return new Map(instances.map(instance => [instance.id, createSimAdminClient(instance, options)]))
// The header timeout ends once headers arrive; body reads need their own
// deadline and a byte budget so a stalled or hostile upstream can neither hang
// status aggregation nor exhaust memory.
export async function readResponseBody(response, { maxBytes = DEFAULT_MAX_BODY_BYTES, bodyTimeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const reader = response.body?.getReader()
if (!reader) return Buffer.alloc(0)
const chunks = []
let size = 0
let failure = null
const timer = setTimeout(() => {
failure = new Error('upstream response body timed out')
reader.cancel(failure).catch(() => {})
}, bodyTimeoutMs)
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
size += value.byteLength
if (size > maxBytes) {
failure = new Error('upstream response body exceeded the size limit')
throw failure
}
chunks.push(Buffer.from(value))
}
} finally {
clearTimeout(timer)
}
if (failure) {
await reader.cancel(failure).catch(() => {})
throw failure
}
return Buffer.concat(chunks)
}
+1 -1
View File
@@ -2,7 +2,7 @@ const AUTH_PATH = /^\/api\/(?:auth(?:\/|$)|login(?:\/|$)|logout(?:\/|$))/i
export const DEFAULT_READ_PATHS = Object.freeze([
'/api/health', '/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity',
'/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation',
'/api/sms/stats', '/api/sms/list', '/api/sms/conversation',
'/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells',
'/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles',
'/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status',
+3 -1
View File
@@ -1,3 +1,5 @@
import { readResponseBody } from '../core.js'
const BLOCKED_REQUEST_HEADERS = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer',
'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization',
@@ -63,5 +65,5 @@ export async function proxyToInstance({ client, request, reply, rest }) {
upstream.headers.forEach((value, name) => {
if (!BLOCKED_RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value)
})
return reply.send(Buffer.from(await upstream.arrayBuffer()))
return reply.send(await readResponseBody(upstream))
}