From 0e8d5f93c92326e5b9ecf884d4baf4c9d1997f4e Mon Sep 17 00:00:00 2001 From: joshua Date: Thu, 27 Aug 2026 09:33:23 +0200 Subject: [PATCH] Reject bot-probe paths on the event alias route before hitting the DB The public [redirectUrl] catch-all route (and its backend counterpart, GET /api/events/by-alias/:redirectUrl) matched any unmatched top-level path, so routine bot/scanner traffic (/wp-login.php, /.env, etc.) was firing a live database query on every hit. That traffic pattern looks like the cause of the P1017 "server has closed the connection" storms and OOM crashes seen from v1.7 onward. Both now reject anything that isn't a plausible alias (letters/numbers/hyphens/underscores) before touching Prisma. Also adds a max_memory_restart safety net to PM2 for both processes. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 8 ++++++++ backend/src/controllers/eventController.js | 11 +++++++++++ ecosystem.config.js | 2 ++ frontend/src/app/[redirectUrl]/page.tsx | 18 ++++++++++++++---- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f39fff3..27ebe5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- The public event-alias route (`/:redirectUrl`, used for short links like `/camp-2025`) is a catch-all matching any unmatched top-level path, so every bot/scanner probe for a nonexistent page (`/wp-login.php`, `/.env`, etc.) was hitting the backend and firing a live database query. Both the frontend route and the `GET /api/events/by-alias/:redirectUrl` endpoint now reject anything that isn't a plausible alias (letters/numbers/hyphens/underscores) before touching the database, instead of forwarding scanner noise straight through — this traffic pattern could exhaust the database connection pool and take the server down under load. + +### Added + +- PM2 (`ecosystem.config.js`) now restarts either process if it exceeds 500MB of memory, instead of letting an unbounded leak run until the OS OOM-kills it. + ## [1.9.4] - 2026-08-26 ### Added diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index e0ba75a..a2afbc8 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -1696,9 +1696,20 @@ const scheduleWhatsappEventAttendees = async (req, res) => { * @route GET /api/events/by-alias/:redirectUrl * @access Public */ +// Aliases are admin-set slugs (e.g. "camp-2025") — see the event wizard's "URL +// Alias" field. This endpoint is public and also the target of the frontend's +// catch-all [redirectUrl] route, so it's what every bot/scanner probe hitting +// an unmatched top-level path (/wp-login.php, /.env, etc.) ends up calling. +// Rejecting non-slug-shaped values here skips a DB round-trip for that traffic. +const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/; + const getEventByAlias = async (req, res) => { const { redirectUrl } = req.params; + if (!VALID_ALIAS.test(redirectUrl)) { + return res.status(404).json({ message: 'Event not found' }); + } + try { const event = await prisma.event.findFirst({ where: { diff --git a/ecosystem.config.js b/ecosystem.config.js index b9d6c1e..b3e3ec6 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -5,6 +5,7 @@ module.exports = { cwd: __dirname + '/backend', script: 'src/index.js', env: { NODE_ENV: 'production' }, + max_memory_restart: '500M', }, { name: 'hope-events-frontend', @@ -12,6 +13,7 @@ module.exports = { script: 'npm', args: 'start -- -p 3000', env: { NODE_ENV: 'production' }, + max_memory_restart: '500M', }, ], }; diff --git a/frontend/src/app/[redirectUrl]/page.tsx b/frontend/src/app/[redirectUrl]/page.tsx index e085651..59c9b30 100644 --- a/frontend/src/app/[redirectUrl]/page.tsx +++ b/frontend/src/app/[redirectUrl]/page.tsx @@ -3,15 +3,25 @@ import { apiFetch } from "@/lib/api"; export const revalidate = 60; +// Event aliases are admin-set slugs (e.g. "camp-2025", "movie-night") — see the +// "URL Alias" field in the event wizard. This catch-all route matches *any* +// unmatched top-level path, so it's also what every bot/scanner probe hits +// (/wp-login.php, /.env, /xmlrpc.php, etc.). Rejecting anything that isn't a +// plausible slug here skips a live DB query for that background noise instead +// of forwarding it straight to the backend. +const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/; + export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) { const { redirectUrl } = await params; let event: any = null; - try { - event = await apiFetch(`/api/events/by-alias/${redirectUrl}`); - } catch (error) { - console.error("Failed to fetch event:", error); + if (VALID_ALIAS.test(redirectUrl)) { + try { + event = await apiFetch(`/api/events/by-alias/${redirectUrl}`); + } catch (error) { + console.error("Failed to fetch event:", error); + } } if (!event || event.message?.toLowerCase().includes("not found")) {