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
70 lines
3.3 KiB
JavaScript
70 lines
3.3 KiB
JavaScript
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',
|
|
'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'accept-encoding',
|
|
])
|
|
const BLOCKED_RESPONSE_HEADERS = new Set([...BLOCKED_REQUEST_HEADERS, 'set-cookie', 'content-encoding'])
|
|
|
|
export class ProxyRequestError extends Error {
|
|
constructor(code) {
|
|
super(code === 'unsupported_content_type' ? 'unsupported proxy content type' : 'invalid proxy path')
|
|
this.code = code
|
|
}
|
|
}
|
|
|
|
const invalidPath = () => new ProxyRequestError('invalid_path')
|
|
|
|
export function safeProxyPath(rest = '') {
|
|
const raw = String(rest || '')
|
|
if (!raw || raw.startsWith('/') || raw.includes('\\')) throw invalidPath()
|
|
let value = raw
|
|
const assertSafe = candidate => {
|
|
if (/[\\?#]/.test(candidate) || candidate.startsWith('//') || candidate.split('/').some(segment => segment === '..' || segment === '.')) throw invalidPath()
|
|
if (/%(?:2f|5c|3f|23|2e)/i.test(candidate)) throw invalidPath()
|
|
}
|
|
for (let depth = 0; depth < 16; depth += 1) {
|
|
assertSafe(value)
|
|
let decoded
|
|
try { decoded = decodeURIComponent(value) } catch { throw invalidPath() }
|
|
if (decoded === value) return `/${raw}`
|
|
value = decoded
|
|
}
|
|
// Refuse inputs whose semantics still change after a bounded number of decodes.
|
|
throw invalidPath()
|
|
}
|
|
|
|
export function proxyHeaders(input = {}) {
|
|
const headers = new Headers()
|
|
for (const [name, value] of Object.entries(input)) {
|
|
if (!BLOCKED_REQUEST_HEADERS.has(name.toLowerCase()) && value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : String(value))
|
|
}
|
|
return headers
|
|
}
|
|
|
|
export async function proxyToInstance({ client, request, reply, rest }) {
|
|
const targetPath = safeProxyPath(rest)
|
|
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
|
|
const target = new URL(`${targetPath}${query}`, new URL(client.instance.url).origin)
|
|
const origin = new URL(client.instance.url).origin
|
|
if (target.origin !== origin) throw invalidPath()
|
|
const headers = proxyHeaders(request.headers)
|
|
let body
|
|
if (!['GET', 'HEAD'].includes(request.method)) {
|
|
const type = (headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
|
if (request.body === undefined && !type) body = undefined
|
|
else if (type.startsWith('multipart/')) throw new ProxyRequestError('unsupported_content_type')
|
|
else if (type === 'application/json' || type.endsWith('+json')) body = request.body === undefined ? undefined : JSON.stringify(request.body)
|
|
else if (type.startsWith('text/') || type === 'application/x-www-form-urlencoded') body = request.body
|
|
else if (Buffer.isBuffer(request.body)) body = request.body
|
|
else throw new ProxyRequestError('unsupported_content_type')
|
|
}
|
|
const upstream = await client.request(target.toString(), { method: request.method, headers, body, redirect: 'manual' })
|
|
reply.code(upstream.status)
|
|
upstream.headers.forEach((value, name) => {
|
|
if (!BLOCKED_RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value)
|
|
})
|
|
return reply.send(await readResponseBody(upstream))
|
|
}
|