Files
multi-simadmin/server/proxy/policy.js
T
chick 2977f75129 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
2026-09-07 01:57:47 +08:00

54 lines
3.0 KiB
JavaScript

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/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',
'/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles',
'/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status',
])
// Write access is deliberately narrower than the readable catalog. Every listed
// operation still requires a one-use server confirmation.
export const DEFAULT_WRITE_PATHS = Object.freeze({
'/api/sms/send': ['POST'],
'/api/data': ['POST', 'PUT', 'PATCH'],
'/api/roaming': ['POST', 'PUT', 'PATCH'],
'/api/airplane-mode': ['POST', 'PUT', 'PATCH'],
'/api/radio-mode': ['POST', 'PUT', 'PATCH'],
'/api/band-lock': ['POST', 'PUT', 'PATCH', 'DELETE'],
'/api/cell-lock': ['POST', 'PUT', 'PATCH', 'DELETE'],
'/api/apn': ['POST', 'PUT', 'PATCH', 'DELETE'],
'/api/device-network/ddns/config': ['POST', 'PUT', 'PATCH'],
'/api/device-network/wlan/profiles': ['POST', 'PUT', 'PATCH', 'DELETE'],
'/api/work-mode': ['POST', 'PUT', 'PATCH'],
'/api/esim/config': ['POST', 'PUT', 'PATCH'],
'/api/esim/profiles': ['POST', 'DELETE'],
'/api/notifications/config': ['POST', 'PUT', 'PATCH'],
'/api/automation/config': ['POST', 'PUT', 'PATCH'],
})
export function createProxyPolicy({ readPaths = DEFAULT_READ_PATHS, writePaths = DEFAULT_WRITE_PATHS } = {}) {
const readable = new Set(readPaths)
const writable = new Map(Object.entries(writePaths).map(([path, methods]) => [path, new Set(methods.map(method => method.toUpperCase()))]))
return Object.freeze({
authorize(method, path) {
method = String(method).toUpperCase()
if (AUTH_PATH.test(path)) return { allowed: false, statusCode: 403, reason: 'authentication endpoints cannot be proxied' }
if (method === 'GET' || method === 'HEAD') return readable.has(path)
? { allowed: true, dangerous: false }
: { allowed: false, statusCode: 403, reason: 'proxy path is not allowed' }
if (!writable.has(path)) return readable.has(path)
? { allowed: false, statusCode: 405, reason: 'proxy method is not allowed' }
: { allowed: false, statusCode: 403, reason: 'proxy path is not allowed' }
return writable.get(path).has(method)
? { allowed: true, dangerous: true }
: { allowed: false, statusCode: 405, reason: 'proxy method is not allowed' }
},
})
}
export const defaultProxyPolicy = createProxyPolicy()