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
@@ -33,7 +33,8 @@ const cookie = (token: string, secure: boolean): string =>
`${CONSOLE_SESSION_COOKIE}=${token}; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${MAX_AGE_SECONDS}`;
const clearCookie = (secure: boolean): string =>
`${CONSOLE_SESSION_COOKIE}=; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=0`;
const usesSecureCookie = (request: FastifyRequest): boolean => request.protocol === 'https';
const usesSecureCookie = (request: FastifyRequest): boolean =>
request.protocol === 'https' || request.headers['x-forwarded-proto'] === 'https';
const problem = (request: FastifyRequest, status: number, code: string, detail: string) => ({
type: 'about:blank',
@@ -83,8 +84,26 @@ const loginSchema = {
properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } },
} as const;
const LOGIN_BUCKETS_MAX = 10_000;
export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void {
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const rememberAttempt = (key: string, attempt: { count: number; resetAt: number }): void => {
// Direct-to-API callers can send arbitrary client-ip values; cap the map so
// bucket keys cannot grow without bound.
if (loginAttempts.size >= LOGIN_BUCKETS_MAX) {
const now = Date.now();
for (const [existing, value] of loginAttempts) {
if (value.resetAt <= now) loginAttempts.delete(existing);
}
while (loginAttempts.size >= LOGIN_BUCKETS_MAX) {
const oldest = loginAttempts.keys().next().value;
if (oldest === undefined) break;
loginAttempts.delete(oldest);
}
}
loginAttempts.set(key, attempt);
};
app.addHook('preHandler', async (request, reply) => {
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
if (!auth.isProtectedPath(pathname)) return;
@@ -121,7 +140,7 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
attempts && attempts.resetAt > now
? attempts
: { count: 0, resetAt: now + LOGIN_WINDOW_MS };
loginAttempts.set(key, { count: active.count + 1, resetAt: active.resetAt });
rememberAttempt(key, { count: active.count + 1, resetAt: active.resetAt });
return handle(request, reply, error);
}
});