301 lines
11 KiB
JavaScript
301 lines
11 KiB
JavaScript
import unirest from 'unirest';
|
|
import { randomBytes, createHmac } from 'crypto';
|
|
import { RateLimiterMemory } from 'rate-limiter-flexible';
|
|
import sanitizeHtml from 'sanitize-html';
|
|
import helmet from 'helmet';
|
|
import csurf from 'csurf';
|
|
|
|
// Environment variable validation
|
|
const requiredEnvVars = [
|
|
'ADMIN_SECRET_KEY',
|
|
'AUTH_ENDPOINT',
|
|
'AUTH_PASSWORD',
|
|
'AUTO_LOGIN_LINK_PREFIX',
|
|
'AUTO_LOGIN_REDIRECT_URL',
|
|
'LINK_ID_BYTES',
|
|
'LINK_EXPIRY_SECONDS',
|
|
'TEMP_LINKS_CLEANUP_INTERVAL_MS'
|
|
];
|
|
|
|
for (const envVar of requiredEnvVars) {
|
|
if (!process.env[envVar]) {
|
|
console.log(`Missing required environment variable: ${envVar}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Initialize security middleware
|
|
const csrfProtection = csurf({ cookie: { secure: true, httpOnly: true, sameSite: 'strict' } });
|
|
const rateLimiter = new RateLimiterMemory({
|
|
points: 10,
|
|
duration: 60 // 10 requests per minute
|
|
});
|
|
|
|
const temporaryLinks = new Map();
|
|
|
|
// Secure cleanup interval
|
|
const cleanupInterval = Math.max(60000, parseInt(process.env.TEMP_LINKS_CLEANUP_INTERVAL_MS, 10));
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [linkId, linkData] of temporaryLinks.entries()) {
|
|
if (linkData.expiresAt < now) {
|
|
temporaryLinks.delete(linkId);
|
|
console.log(`Cleaned up expired link: ${linkId}`);
|
|
}
|
|
}
|
|
}, cleanupInterval);
|
|
|
|
// Input sanitization and validation
|
|
const sanitizeInput = (input) => {
|
|
if (typeof input !== 'string') {
|
|
console.log(`Invalid input type: expected string, got ${typeof input}`);
|
|
return null;
|
|
}
|
|
// Allow alphanumeric characters and underscores
|
|
if (!/^[a-zA-Z0-9_]+$/.test(input)) {
|
|
console.log(`Invalid input format: ${input}`);
|
|
return null;
|
|
}
|
|
return sanitizeHtml(input);
|
|
};
|
|
|
|
// Generate secure token
|
|
const generateSecureToken = (bytes) => {
|
|
const buffer = randomBytes(Math.max(16, parseInt(bytes, 10)));
|
|
const hmac = createHmac('sha256', process.env.ADMIN_SECRET_KEY);
|
|
return hmac.update(buffer).digest('hex');
|
|
};
|
|
|
|
// Check if IP is local (for development)
|
|
const isLocalIp = (ip) => {
|
|
return (
|
|
ip === '127.0.0.1' ||
|
|
ip === '::1' ||
|
|
ip.startsWith('192.168.') ||
|
|
ip.startsWith('10.')
|
|
);
|
|
};
|
|
|
|
// Get real client IP from Cloudflare or fallback
|
|
const getRealIp = (req) => {
|
|
// Prioritize CF-Connecting-IP for Cloudflare
|
|
const cfConnectingIp = req.headers['cf-connecting-ip'];
|
|
if (cfConnectingIp) {
|
|
return cfConnectingIp;
|
|
}
|
|
|
|
// Fallback to X-Forwarded-For
|
|
const forwardedFor = req.headers['x-forwarded-for'];
|
|
if (forwardedFor) {
|
|
// Take the first IP in the chain (original client IP)
|
|
return forwardedFor.split(',')[0].trim();
|
|
}
|
|
|
|
// Fallback to req.ip
|
|
return req.ip;
|
|
};
|
|
|
|
// Generate nonce for CSP
|
|
const generateNonce = () => {
|
|
return randomBytes(16).toString('base64');
|
|
};
|
|
|
|
export async function generateLoginLink(req, res) {
|
|
// Apply security headers
|
|
helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
scriptSrc: ["'self'"]
|
|
}
|
|
},
|
|
xFrameOptions: { action: 'deny' }
|
|
})(req, res, async () => {
|
|
try {
|
|
// Rate limiting with real IP
|
|
await rateLimiter.consume(getRealIp(req));
|
|
|
|
// CSRF protection
|
|
csrfProtection(req, res, async () => {
|
|
const { secretKey, username } = req.body;
|
|
|
|
// Validate inputs
|
|
if (!sanitizeInput(secretKey) || secretKey !== process.env.ADMIN_SECRET_KEY) {
|
|
console.log(`Invalid secret key attempt from IP: ${getRealIp(req)}`);
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
const sanitizedUsername = sanitizeInput(username);
|
|
if (!sanitizedUsername) {
|
|
console.log(`Invalid username attempt from IP: ${getRealIp(req)}, username: ${username}`);
|
|
return res.status(400).json({ error: 'Invalid username' });
|
|
}
|
|
|
|
// Secure API request
|
|
const tokenResponse = await unirest
|
|
.post(process.env.AUTH_ENDPOINT)
|
|
.headers({
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-Request-ID': generateSecureToken(16)
|
|
})
|
|
.send({ username: sanitizedUsername, password: process.env.AUTH_PASSWORD })
|
|
.timeout(5000);
|
|
|
|
if (!tokenResponse.body.token) {
|
|
console.log(`Failed to generate API key for username: ${sanitizedUsername}`);
|
|
return res.status(500).json({ error: 'Authentication service error' });
|
|
}
|
|
|
|
const apiKey = tokenResponse.body.token;
|
|
const linkId = generateSecureToken(process.env.LINK_ID_BYTES);
|
|
const loginLink = `${process.env.AUTO_LOGIN_LINK_PREFIX}${linkId}`;
|
|
|
|
// Store link data with real IP
|
|
temporaryLinks.set(linkId, {
|
|
apiKey,
|
|
username: sanitizedUsername,
|
|
expiresAt: Date.now() + Math.min(3600000, parseInt(process.env.LINK_EXPIRY_SECONDS, 10) * 1000),
|
|
ip: getRealIp(req),
|
|
userAgent: req.get('User-Agent') || 'Discord-Bot-Request'
|
|
});
|
|
|
|
// Secure timeout
|
|
setTimeout(() => {
|
|
temporaryLinks.delete(linkId);
|
|
console.log(`Expired link removed: ${linkId}`);
|
|
}, Math.min(3600000, parseInt(process.env.LINK_EXPIRY_SECONDS, 10) * 1000));
|
|
|
|
console.log(`Generated login link for username: ${sanitizedUsername} from IP: ${getRealIp(req)}, userAgent: ${req.get('User-Agent') || 'Discord-Bot-Request'}`);
|
|
res.json({ loginLink });
|
|
});
|
|
} catch (error) {
|
|
console.log(`Error generating login link: ${error.message}`);
|
|
res.status(500).json({ error: 'Server error' });
|
|
}
|
|
});
|
|
}
|
|
|
|
export function handleAutoLogin(req, res) {
|
|
// Generate nonce for CSP
|
|
const nonce = generateNonce();
|
|
|
|
// Apply security headers with nonce
|
|
helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
scriptSrc: ["'self'", `'nonce-${nonce}'`]
|
|
}
|
|
},
|
|
xFrameOptions: { action: 'deny' }
|
|
})(req, res, () => {
|
|
const { linkId } = req.params;
|
|
const sanitizedLinkId = sanitizeInput(linkId);
|
|
const linkData = temporaryLinks.get(sanitizedLinkId);
|
|
|
|
if (!linkData || linkData.expiresAt < Date.now()) {
|
|
temporaryLinks.delete(sanitizedLinkId);
|
|
console.log(`Expired or invalid login attempt for link: ${sanitizedLinkId} from IP: ${getRealIp(req)}`);
|
|
|
|
return res.send(`
|
|
<html>
|
|
<head>
|
|
<meta http-equiv="refresh" content="3;url=${encodeURI(process.env.AUTO_LOGIN_REDIRECT_URL)}">
|
|
<meta name="robots" content="noindex">
|
|
<style>
|
|
body { display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #111827; font-family: 'Arial', sans-serif; }
|
|
.notification { background-color: #1f2937; color: white; padding: 16px; border-radius: 8px; display: flex; flex-direction: column; align-items: center; gap: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); max-width: 400px; width: 100%; }
|
|
h1 { font-size: 2.25em; color: white; text-align: center; margin: 0; }
|
|
.spinner { border: 4px solid rgba(255, 255, 255, 0.3); border-top: 4px solid #ffffff; border-radius: 50%; width: 24px; height: 24px; animation: spin 1s linear infinite; }
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="notification">
|
|
<span class="spinner"></span>
|
|
<h1>Login Expired</h1>
|
|
<h1>Redirecting...</h1>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`);
|
|
}
|
|
|
|
// Verify client consistency
|
|
const isIpMatch = linkData.ip === getRealIp(req);
|
|
const isUserAgentMatch = linkData.userAgent === (req.get('User-Agent') || 'Discord-Bot-Request');
|
|
const isLocal = isLocalIp(getRealIp(req)) && isLocalIp(linkData.ip);
|
|
const strictUserAgentCheck = process.env.STRICT_USER_AGENT_CHECK === 'true';
|
|
|
|
if (strictUserAgentCheck && !isUserAgentMatch && !isLocal) {
|
|
temporaryLinks.delete(sanitizedLinkId);
|
|
console.log(
|
|
`Suspicious login attempt for link: ${sanitizedLinkId} from IP: ${getRealIp(req)}, ` +
|
|
`expected IP: ${linkData.ip}, isLocal: ${isLocal}, ` +
|
|
`userAgentMatch: ${isUserAgentMatch}, ` +
|
|
`expectedUserAgent: ${linkData.userAgent}, ` +
|
|
`actualUserAgent: ${req.get('User-Agent') || 'Discord-Bot-Request'}`
|
|
);
|
|
return res.status(403).json({ error: 'Invalid session' });
|
|
}
|
|
|
|
if (!isUserAgentMatch) {
|
|
console.log(
|
|
`Non-critical user-agent mismatch for link: ${sanitizedLinkId} from IP: ${getRealIp(req)}, ` +
|
|
`expectedUserAgent: ${linkData.userAgent}, ` +
|
|
`actualUserAgent: ${req.get('User-Agent') || 'Discord-Bot-Request'}`
|
|
);
|
|
}
|
|
|
|
temporaryLinks.delete(sanitizedLinkId);
|
|
console.log(`Successful auto-login for username: ${linkData.username} from IP: ${getRealIp(req)}, userAgent: ${req.get('User-Agent') || 'Discord-Bot-Request'}`);
|
|
|
|
// Secure API key storage with additional client-side security and debugging
|
|
res.send(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Secure Auto Login</title>
|
|
<meta name="robots" content="noindex">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-${nonce}'">
|
|
<meta http-equiv="refresh" content="5;url=/">
|
|
<style>
|
|
body { display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #111827; font-family: 'Arial', sans-serif; }
|
|
.notification { background-color: #1f2937; color: white; padding: 16px; border-radius: 8px; display: flex; flex-direction: column; align-items: center; gap: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); max-width: 400px; width: 100%; }
|
|
h1 { font-size: 2.25em; color: white; text-align: center; margin: 0; }
|
|
.spinner { border: 4px solid rgba(255, 255, 255, 0.3); border-top: 4px solid #ffffff; border-radius: 50%; width: 24px; height: 24px; animation: spin 1s linear infinite; }
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="notification">
|
|
<span class="spinner"></span>
|
|
<h1>Securely logging in...</h1>
|
|
</div>
|
|
<script nonce="${nonce}">
|
|
(function() {
|
|
console.log('Auto-login script started');
|
|
const apiKey = '${sanitizeHtml(linkData.apiKey)}';
|
|
console.log('API key retrieved');
|
|
try {
|
|
localStorage.setItem('apiKey', apiKey);
|
|
console.log('API key stored in localStorage');
|
|
sessionStorage.setItem('sessionTimestamp', Date.now());
|
|
console.log('Session timestamp stored');
|
|
window.location.href = '/';
|
|
console.log('Redirect initiated to /');
|
|
} catch (e) {
|
|
console.error('Storage error:', e.message);
|
|
window.location.href = '${encodeURI(process.env.AUTO_LOGIN_REDIRECT_URL)}';
|
|
console.log('Fallback redirect initiated to AUTO_LOGIN_REDIRECT_URL');
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
});
|
|
} |