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")) {