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:
+40
-5
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user