diff --git a/infrastructure/web/middleware/bot-detect.js b/infrastructure/web/middleware/bot-detect.js new file mode 100644 index 0000000..8ec759a --- /dev/null +++ b/infrastructure/web/middleware/bot-detect.js @@ -0,0 +1,279 @@ +// Exhaustive bot detection middleware for Express +// Flags likely bots and sets res.locals.suspectedBot +// Uses multiple signals: User-Agent, Accept, Referer, Accept-Language, cookies, IP, and request rate + +const knownBotUserAgents = [ + /HeadlessChrome/i, + /PhantomJS/i, + /Puppeteer/i, + /node\.js/i, + /curl/i, + /wget/i, + /python/i, + /Go-http-client/i, + /Java\//i, + /libwww-perl/i, + /scrapy/i, + /httpclient/i, + /http_request2/i, + /lwp::simple/i, + /okhttp/i, + /mechanize/i, + /axios/i, + /rest-client/i, + /httpie/i, + /powershell/i, + /http.rb/i, + /fetch/i, + /httpclient/i, + /spider/i, + /bot/i, + /spider/i, + /crawler/i, + /slurp/i, + /bingbot/i, + /yandex/i, + /duckduckgo/i, + /baiduspider/i, + /sogou/i, + /exabot/i, + /facebot/i, + /ia_archiver/i, + /Google-Read-Aloud/i, // Google Read Aloud + /Google-Structured-Data-Testing-Tool/i, + /Google-PageRenderer/i, + /Google Favicon/i, + /Googlebot/i, + /AdsBot-Google/i, + /Feedfetcher-Google/i, + /APIs-Google/i, + /bingpreview/i, + /facebookexternalhit/i, + /WhatsApp/i, + /TelegramBot/i, + /Slackbot/i, + /Discordbot/i, + /Applebot/i, + /DuckDuckBot/i, + /embedly/i, + /LinkedInBot/i, + /outbrain/i, + /pinterest/i, + /quora link preview/i, + /rogerbot/i, + /showyoubot/i, + /SkypeUriPreview/i, + /Slack-ImgProxy/i, + /Twitterbot/i, + /vkShare/i, + /W3C_Validator/i, + /redditbot/i, + /FlipboardProxy/i, + /Qwantify/i, + /SEMrushBot/i, + /AhrefsBot/i, + /MJ12bot/i, + /DotBot/i, + /BLEXBot/i, + /YandexBot/i, + /Screaming Frog/i, + /SiteAuditBot/i, + /UptimeRobot/i, + /Pingdom/i, + /StatusCake/i, + /ZoominfoBot/i, + /Google-Safety/i, + /Lighthouse/i, + /Accessibility/i, + /NVDA/i, + /JAWS/i, + /VoiceOver/i, + /ScreenReader/i, + /axe-core/i, + /pa11y/i, + /waveapi/i, + /tenon/i, + /Siteimprove/i, + /SiteAnalyzer/i, + /Sitebulb/i, + /SEO PowerSuite/i, + /SEOsitecheckup/i, + /SEO Crawler/i, + /SEO-Checker/i, + /SEO-Tool/i, + /SEO-Analyzer/i, + /SEO-Tester/i, + /SEO-SpyGlass/i, + /SEO-Toolkit/i, + /SEO-Tools/i, + /SEO-Profiler/i, + /SEO-Checker/i, + /SEO-Tool/i, + /SEO-Analyzer/i, + /SEO-Tester/i, + /SEO-SpyGlass/i, + /SEO-Toolkit/i, + /SEO-Tools/i, + /SEO-Profiler/i +]; + +const knownHeadlessIndicators = [ + 'Headless', + 'PhantomJS', + 'Puppeteer', + 'Selenium', + 'Nightmare', + 'SlimerJS', + 'Zombie', + 'CasperJS', + 'TrifleJS', + 'HtmlUnit', + 'Splash', + 'Playwright' +]; + +// Additional bypass and automation checks +function hasSuspiciousHeaders(req) { + // Some automation tools set these headers + if (req.get('X-Requested-With') && req.get('X-Requested-With').toLowerCase() !== 'xmlhttprequest') return true; + if (req.get('X-Purpose')) return true; + if (req.get('X-Moz')) return true; + if (req.get('X-ATT-DeviceId')) return true; + if (req.get('X-Wap-Profile')) return true; + if (req.get('X-OperaMini-Phone-UA')) return true; + if (req.get('X-OperaMini-Features')) return true; + if (req.get('X-Device-User-Agent')) return true; + if (req.get('X-Original-User-Agent')) return true; + if (req.get('X-Device-Id')) return true; + if (req.get('X-Forwarded-For') && req.get('X-Forwarded-For').split(',').length > 3) return true; + return false; +} + +// In-memory request rate tracking (per IP) +const requestLog = {}; +const RATE_WINDOW_MS = 10 * 1000; // 10 seconds +const MAX_REQUESTS_PER_WINDOW = 30; + +function isRapidRequester(ip) { + const now = Date.now(); + if (!requestLog[ip]) requestLog[ip] = []; + // Remove old entries + requestLog[ip] = requestLog[ip].filter(ts => now - ts < RATE_WINDOW_MS); + requestLog[ip].push(now); + return requestLog[ip].length > MAX_REQUESTS_PER_WINDOW; +} + +module.exports = function botDetect(req, res, next) { + // If suppression cookie is set, skip detection + if (req.cookies && req.cookies.bot_check_passed) { + res.locals.suspectedBot = false; + return next(); + } + + let score = 0; + const reasons = []; + + // Header and request info (declare all before use) + const ua = req.get('User-Agent') || ''; + const accept = req.get('Accept') || ''; + const referer = req.get('Referer') || ''; + const acceptLang = req.get('Accept-Language') || ''; + const hasCookies = !!req.headers.cookie; + const ip = req.ip || req.connection.remoteAddress; + const path = req.path || ''; + + // Check for suspicious/bypass headers + if (hasSuspiciousHeaders(req)) { + score += 2; + reasons.push('Suspicious/bypass headers'); + } + + // Google Read Aloud and similar tools: look for Accept header with 'application/ssml+xml' or 'text/speech' + if (accept.includes('ssml+xml') || accept.includes('text/speech')) { + score += 2; + reasons.push('Speech synthesis Accept header'); + } + + // Accessibility Accept headers (screen readers, etc) + if (accept.includes('application/x-nvda') || accept.includes('application/x-jaws')) { + score += 1; + reasons.push('Accessibility Accept header'); + } + + // Check for automation framework cookies (common for Selenium, Puppeteer, etc) + if (req.headers.cookie && (req.headers.cookie.includes('puppeteer') || req.headers.cookie.includes('selenium'))) { + score += 2; + reasons.push('Automation framework cookie'); + } + + // User-Agent checks + if (!ua) { + score += 2; + reasons.push('Missing User-Agent'); + } else { + if (knownBotUserAgents.some(pat => pat.test(ua))) { + score += 3; + reasons.push('Known bot User-Agent'); + } + if (knownHeadlessIndicators.some(ind => ua.includes(ind))) { + score += 2; + reasons.push('Headless browser indicator'); + } + if (ua.length < 10) { + score += 1; + reasons.push('Suspiciously short User-Agent'); + } + } + + // Accept header + if (!accept || accept === '*/*') { + score += 1; + reasons.push('Suspicious Accept header'); + } + + // Referer + if (!referer && req.method === 'POST') { + score += 1; + reasons.push('Missing Referer on POST'); + } + + // Accept-Language + if (!acceptLang) { + score += 1; + reasons.push('Missing Accept-Language'); + } + + // Cookies + if (!hasCookies) { + score += 1; + reasons.push('No cookies sent'); + } + + // IP checks (basic, not using blocklists) + if (isRapidRequester(ip)) { + score += 2; + reasons.push('Rapid request rate'); + } + + // HTTP method + if (req.method && !['GET', 'POST', 'HEAD'].includes(req.method)) { + score += 1; + reasons.push('Unusual HTTP method'); + } + + // Path checks (bots often hit /robots.txt, /admin, etc) + if (['/robots.txt', '/admin', '/wp-login.php', '/xmlrpc.php'].includes(path)) { + score += 2; + reasons.push('Bot-targeted path'); + } + + // If score is high, flag as bot + const threshold = 3; + if (score >= threshold) { + res.locals.suspectedBot = true; + res.locals.botDetectionReasons = reasons; + } else { + res.locals.suspectedBot = false; + } + next(); +} \ No newline at end of file diff --git a/infrastructure/web/public/images/logo.ico b/infrastructure/web/public/images/favicon.ico similarity index 100% rename from infrastructure/web/public/images/logo.ico rename to infrastructure/web/public/images/favicon.ico diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css index 07557f3..d14c09a 100644 --- a/infrastructure/web/public/stylesheets/custom.css +++ b/infrastructure/web/public/stylesheets/custom.css @@ -1,3 +1,71 @@ +/* Bot detection popup */ + +.bot-detect-popup-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--overlay-purple-50, rgba(155, 77, 202, 0.5)); + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.bot-detect-popup-modal { + background: var(--color-bg-medium, #2F2B36); + color: var(--color-text-primary, #cccccc); + padding: 2em 2.5em; + border-radius: 14px; + max-width: 420px; + width: 90vw; + text-align: center; + box-shadow: 0 2px 24px var(--overlay-black-45, rgba(0, 0, 0, 0.45)); + font-size: 1.1em; + border: 1.5px solid var(--color-accent-purple, #9b4dca); +} + +.bot-detect-popup-modal h2 { + margin-top: 0; + color: var(--color-accent-purple, #9b4dca); + font-weight: 700; + font-size: 1.6em; +} + +.bot-detect-popup-modal a { + color: var(--color-accent-purple-bright, #6c5ce7); + text-decoration: underline; + font-weight: 500; +} + +.bot-detect-popup-modal a:hover { + color: var(--color-accent-orange, #ca5414); +} + +.bot-detect-popup-modal button { + margin-top: 1.5em; + font-size: 1em; + border-radius: 6px; + background: var(--color-accent-purple, #9b4dca); + color: #fff; + border: none; + cursor: pointer; + font-weight: 600; + box-shadow: 0 1px 4px var(--overlay-black-30, rgba(0, 0, 0, 0.3)); + transition: background 0.15s; +} + +.bot-detect-popup-modal button:hover { + background: var(--color-accent-purple-bright, #6c5ce7); +} + +.bot-popup-no-scroll { + overflow: hidden !important; + height: 100vh !important; +} + :root { /* Base colors */ --color-bg-dark: #131516; diff --git a/infrastructure/web/views/bot-popup.twig b/infrastructure/web/views/bot-popup.twig new file mode 100644 index 0000000..a4281cc --- /dev/null +++ b/infrastructure/web/views/bot-popup.twig @@ -0,0 +1,78 @@ + + + + + + Automation Detected | 48hr.email + + + + + + + Automation Detected | 48hr.email + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Automation Detected

+

We have detected that you may be using automation or a bot to access this site.

+ If you want to automate things, please use our official API.

+ See the API Documentation for details.

+ To continue, please close this popup manually. If you think you're seeing this by mistake, let us know!

+ +
+
+ + + diff --git a/infrastructure/web/views/layout.twig b/infrastructure/web/views/layout.twig index 4a34dd4..fe62601 100644 --- a/infrastructure/web/views/layout.twig +++ b/infrastructure/web/views/layout.twig @@ -41,8 +41,8 @@ - - + + diff --git a/infrastructure/web/web.js b/infrastructure/web/web.js index 962332a..72eb90a 100644 --- a/infrastructure/web/web.js +++ b/infrastructure/web/web.js @@ -1,3 +1,4 @@ +const botDetect = require('./middleware/bot-detect') const path = require('path') const http = require('http') const debug = require('debug')('48hr-email:server') @@ -58,6 +59,24 @@ app.use(session({ cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours })) + + +// Bot detection middleware (after cookies/session, before routes) +app.use(botDetect) + +// If bot detected and not suppressed, render only the popup page and halt further processing +app.use((req, res, next) => { + // Allow static assets (css, js, images, favicon, etc) and all /api/* routes even if bot detected + if (res.locals.suspectedBot && !(req.cookies && req.cookies.bot_check_passed)) { + const asset = req.path.match(/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot)$/i); + if (asset) return next(); + if (req.path.startsWith('/api/')) return next(); + // For non-asset, non-API requests, render only the popup page + return res.status(200).render('bot-popup'); + } + next(); +}); + // Clear lock session data when user goes Home (but preserve authentication) app.get('/', (req, res, next) => { if (req.session && req.session.lockedInbox) { @@ -243,4 +262,4 @@ server.on('listening', () => { server.emit('ready') }) -module.exports = { app, io, server } +module.exports = { app, io, server } \ No newline at end of file diff --git a/package.json b/package.json index adec2ce..b34c645 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "48hr.email", - "version": "2.3.2", + "version": "2.3.3", "private": false, "description": "48hr.email is your favorite open-source tempmail client.", "keywords": [