AI_generation / server.js
MineRoma's picture
Upload server.js
cef42ee verified
Raw History Blame Contribute Delete
19.8 kB
// ═══════════════════════════════════════════
// AnBiMessenger – E2EE Messenger Backend
// Express 4 + Sharding + Security Hardened
// ═══════════════════════════════════════════
import express from 'express';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { readFileSync, existsSync } from 'fs';
import { createHmac, timingSafeEqual } from 'crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 7860;
// ── Environment Variables ──
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
const GIST_ID = process.env.GIST_ID || '';
const SHARD_PREFIX = 'messages_part_';
const SHARD_SIZE = 300;
const MAX_PAYLOAD_SIZE = 500000;
// ── Server-side secret for HMAC validation ──
// Derived from GITHUB_TOKEN so it's unique per deployment but never exposed
const SERVER_SECRET = GITHUB_TOKEN
? createHmac('sha256', 'anbi-messenger-v2').update(GITHUB_TOKEN).digest('hex')
: 'dev-mode-secret-change-me';
// ── Middleware ──
app.use(express.json({ limit: '10mb' }));
// Security headers
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'no-referrer');
next();
});
// Serve static files from dist
const distPath = join(__dirname, 'dist');
if (existsSync(distPath)) {
app.use(express.static(distPath));
}
// ── In-Memory Cache ──
let cachedMessages = [];
let lastFetchTime = 0;
let fetchEtag = '';
// ── Write Queue ──
let writeQueue = [];
let isFlushingQueue = false;
// ── Rate Limiting (per IP) ──
const rateLimitMap = new Map();
const RATE_WINDOW = 5000;
const RATE_MAX = 15;
function getClientIP(req) {
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown';
}
function checkRate(req) {
const ip = getClientIP(req);
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now - entry.start > RATE_WINDOW) {
rateLimitMap.set(ip, { start: now, count: 1 });
return true;
}
entry.count++;
return entry.count <= RATE_MAX;
}
// Cleanup rate limit map
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of rateLimitMap.entries()) {
if (now - entry.start > RATE_WINDOW * 2) rateLimitMap.delete(ip);
}
}, 30000);
// ═══════════════════════════════════════════
// AUTH: HMAC Token Validation
// ═══════════════════════════════════════════
//
// Client sends: Authorization: HMAC <token>
// Token = HMAC-SHA256(SERVER_SECRET, senderHash + ":" + timestamp)
// timestamp must be within 5 minutes window
//
// This ensures:
// 1. Only clients who know the seed phrase can compute senderHash
// 2. The token is time-limited (replay window = 5 min)
// 3. SERVER_SECRET is never exposed to client — but both sides
// derive the same HMAC differently:
// - Server uses GITHUB_TOKEN-derived secret
// - Client sends its own HMAC; server validates structure
//
// ACTUALLY: Since we can't share SERVER_SECRET with the client,
// we use a different approach:
// Client sends: X-Auth-Hash: <senderHash>
// X-Auth-Token: HMAC-SHA256(seedPhrase, senderHash + ":" + timestamp)
// X-Auth-Time: <timestamp>
// Server cannot verify the seed phrase (doesn't know it), BUT:
// - Server stores the token from the FIRST request as the "session token" for that senderHash
// - Subsequent requests must use the SAME token derivation
// - This proves the client has the same seed phrase across requests
//
// SIMPLER SECURE APPROACH:
// Since all messages are E2E encrypted and the server is just a relay,
// the real security is:
// 1. IDOR Prevention: Server strictly checks senderHash ownership for edit/delete
// 2. Data Access: Require a room token (hash of seed phrase) to fetch messages
// 3. The room token proves you know the seed phrase without revealing it
// Room token registry: maps roomToken -> Set of senderHashes that have posted
const roomRegistry = new Map(); // roomToken -> { hashes: Set, lastSeen: number }
function computeRoomToken(seedPhrase) {
// Client computes: SHA256(seedPhrase) -> first 16 chars
// We can't compute this server-side. Client sends it.
// But we validate it's consistent.
return null;
}
/**
* Validate auth headers.
* Required headers:
* X-Room-Token: SHA256(seedPhrase).substring(0, 16) — proves knowledge of seed
* X-Sender-Hash: the sender's hash (for write operations)
*
* For GET /api/messages: only X-Room-Token required
* For POST /api/messages: X-Room-Token + X-Sender-Hash required
*/
function validateAuth(req, requireSender = false) {
const roomToken = req.headers['x-room-token'];
const senderHash = req.headers['x-sender-hash'];
if (!roomToken || typeof roomToken !== 'string' || roomToken.length < 8 || roomToken.length > 64) {
return { valid: false, error: 'Missing or invalid X-Room-Token header', status: 401 };
}
// Sanitize
if (!/^[a-f0-9]+$/i.test(roomToken)) {
return { valid: false, error: 'Invalid room token format', status: 401 };
}
if (requireSender) {
if (!senderHash || typeof senderHash !== 'string' || senderHash.length < 4 || senderHash.length > 16) {
return { valid: false, error: 'Missing or invalid X-Sender-Hash header', status: 401 };
}
if (!/^[a-f0-9]+$/i.test(senderHash)) {
return { valid: false, error: 'Invalid sender hash format', status: 401 };
}
}
return { valid: true, roomToken, senderHash };
}
// ═══════════════════════════════════════════
// SHARDING
// ═══════════════════════════════════════════
function shardName(i) {
return `${SHARD_PREFIX}${i}.json`;
}
function splitIntoShards(msgs) {
const shards = {};
for (let i = 0; i < msgs.length; i += SHARD_SIZE) {
const idx = Math.floor(i / SHARD_SIZE) + 1;
shards[shardName(idx)] = msgs.slice(i, i + SHARD_SIZE);
}
if (Object.keys(shards).length === 0) {
shards[shardName(1)] = [];
}
return shards;
}
// ═══════════════════════════════════════════
// GIST API
// ═══════════════════════════════════════════
async function fetchFromGist() {
if (!GITHUB_TOKEN || !GIST_ID) return;
try {
const headers = {
'Authorization': `Bearer ${GITHUB_TOKEN}`,
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};
if (fetchEtag) headers['If-None-Match'] = fetchEtag;
const resp = await fetch(`https://api.github.com/gists/${GIST_ID}`, { headers });
if (resp.status === 304) {
lastFetchTime = Date.now();
return;
}
if (!resp.ok) {
console.error(`[Gist Fetch] HTTP ${resp.status}: ${resp.statusText}`);
return;
}
const etag = resp.headers.get('etag');
if (etag) fetchEtag = etag;
const gist = await resp.json();
const all = [];
// Try sharded files first
const shardFiles = Object.keys(gist.files || {})
.filter(n => n.startsWith(SHARD_PREFIX))
.sort((a, b) => {
const na = parseInt(a.replace(SHARD_PREFIX, '').replace('.json', ''));
const nb = parseInt(b.replace(SHARD_PREFIX, '').replace('.json', ''));
return na - nb;
});
if (shardFiles.length > 0) {
for (const fn of shardFiles) {
try {
const content = gist.files[fn]?.content;
if (content) {
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) all.push(...parsed);
}
} catch (e) {
console.error(`[Gist] Parse error in ${fn}:`, e.message);
}
}
} else {
// Legacy single-file fallback
try {
const legacy = gist.files?.['messages.json']?.content;
if (legacy) {
const parsed = JSON.parse(legacy);
if (Array.isArray(parsed)) all.push(...parsed);
}
} catch (e) {
console.error('[Gist] Parse error in messages.json:', e.message);
}
}
cachedMessages = all;
lastFetchTime = Date.now();
console.log(`[Gist Fetch] OK – ${all.length} msgs (${shardFiles.length || 1} shard(s))`);
} catch (e) {
console.error('[Gist Fetch] Error:', e.message);
}
}
async function flushQueue() {
if (isFlushingQueue || writeQueue.length === 0 || !GITHUB_TOKEN || !GIST_ID) return;
isFlushingQueue = true;
const batch = [...writeQueue];
writeQueue = [];
try {
let messages = [...cachedMessages];
for (const op of batch) {
if (op.action === 'add') {
if (!messages.some(m => m.id === op.data.id)) {
messages.push(op.data);
}
} else if (op.action === 'edit') {
const idx = messages.findIndex(m => m.id === op.data.id);
// CRITICAL: Verify senderHash matches the STORED message
if (idx !== -1 && messages[idx].senderHash === op.data.senderHash) {
messages[idx] = { ...messages[idx], payload: op.data.payload, lastModified: op.data.lastModified };
}
} else if (op.action === 'delete') {
const idx = messages.findIndex(m => m.id === op.data.id);
// CRITICAL: Verify senderHash matches the STORED message
if (idx !== -1 && messages[idx].senderHash === op.data.senderHash) {
messages.splice(idx, 1);
}
}
}
const shards = splitIntoShards(messages);
const files = {};
for (const [fn, content] of Object.entries(shards)) {
files[fn] = { content: JSON.stringify(content) };
}
files['messages.json'] = { content: '[]' };
const resp = await fetch(`https://api.github.com/gists/${GIST_ID}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${GITHUB_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
body: JSON.stringify({ files }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
const etag = resp.headers.get('etag');
if (etag) fetchEtag = etag;
cachedMessages = messages;
lastFetchTime = Date.now();
console.log(`[Gist Flush] OK – ${batch.length} op(s), total: ${messages.length}`);
} catch (e) {
writeQueue = [...batch, ...writeQueue];
console.error('[Gist Flush] Error:', e.message);
} finally {
isFlushingQueue = false;
}
}
// ═══════════════════════════════════════════
// API ROUTES
// ═══════════════════════════════════════════
// ── GET /api/messages ──
// Requires X-Room-Token header (SHA256 of seed phrase, first 16 chars)
// Only returns messages — they're encrypted, so useless without the seed
// But without the room token, you get nothing
app.get('/api/messages', (req, res) => {
if (!checkRate(req)) return res.status(429).json({ error: 'Rate limited' });
const auth = validateAuth(req, false);
if (!auth.valid) {
return res.status(auth.status).json({ error: auth.error });
}
// Filter messages: only return messages that belong to this room
// Each message has a roomToken stored with it
const roomMessages = cachedMessages.filter(m => m.roomToken === auth.roomToken);
// Return only the fields the client needs — strip roomToken (internal)
const sanitized = roomMessages.map(m => ({
id: m.id,
senderHash: m.senderHash,
payload: m.payload,
timestamp: m.timestamp,
lastModified: m.lastModified,
}));
res.json(sanitized);
});
// ── POST /api/messages ──
// Requires X-Room-Token + X-Sender-Hash headers
app.post('/api/messages', (req, res) => {
if (!checkRate(req)) return res.status(429).json({ error: 'Rate limited' });
const auth = validateAuth(req, true);
if (!auth.valid) {
return res.status(auth.status).json({ error: auth.error });
}
const { action } = req.body;
if (action === 'add') {
const { id, payload, timestamp } = req.body;
if (!id || !payload || !timestamp) {
return res.status(400).json({ error: 'Missing fields: id, payload, timestamp' });
}
if (typeof payload !== 'string' || payload.length > MAX_PAYLOAD_SIZE) {
return res.status(413).json({ error: 'Payload too large or invalid' });
}
const msg = {
id: String(id).substring(0, 64),
senderHash: String(auth.senderHash).substring(0, 16),
roomToken: String(auth.roomToken).substring(0, 16),
payload,
timestamp: Number(timestamp),
};
// Duplicate check
if (cachedMessages.some(m => m.id === msg.id)) {
return res.json({ ok: true, queued: writeQueue.length, duplicate: true });
}
if (writeQueue.some(op => op.action === 'add' && op.data.id === msg.id)) {
return res.json({ ok: true, queued: writeQueue.length, duplicate: true });
}
writeQueue.push({ action: 'add', data: msg });
cachedMessages.push(msg);
return res.json({ ok: true, queued: writeQueue.length });
} else if (action === 'edit') {
const { id, payload, lastModified } = req.body;
if (!id || !payload || !lastModified) {
return res.status(400).json({ error: 'Missing fields for edit' });
}
if (typeof payload !== 'string' || payload.length > MAX_PAYLOAD_SIZE) {
return res.status(413).json({ error: 'Payload too large' });
}
// ── IDOR PROTECTION: Find message and verify ownership ──
const existingMsg = cachedMessages.find(m => m.id === id);
if (!existingMsg) {
return res.status(404).json({ error: 'Message not found' });
}
if (existingMsg.senderHash !== auth.senderHash) {
console.warn(`[SECURITY] IDOR attempt: ${auth.senderHash} tried to edit message owned by ${existingMsg.senderHash} (IP: ${getClientIP(req)})`);
return res.status(403).json({ error: 'Forbidden: you can only edit your own messages' });
}
if (existingMsg.roomToken !== auth.roomToken) {
console.warn(`[SECURITY] Cross-room attempt: ${auth.senderHash} tried to edit message in different room (IP: ${getClientIP(req)})`);
return res.status(403).json({ error: 'Forbidden: message belongs to a different room' });
}
writeQueue.push({
action: 'edit',
data: { id, senderHash: auth.senderHash, payload, lastModified: Number(lastModified) }
});
const idx = cachedMessages.findIndex(m => m.id === id);
if (idx !== -1) {
cachedMessages[idx] = { ...cachedMessages[idx], payload, lastModified: Number(lastModified) };
}
return res.json({ ok: true, queued: writeQueue.length });
} else if (action === 'delete') {
const { id } = req.body;
if (!id) {
return res.status(400).json({ error: 'Missing message id' });
}
// ── IDOR PROTECTION: Find message and verify ownership ──
const existingMsg = cachedMessages.find(m => m.id === id);
if (!existingMsg) {
return res.status(404).json({ error: 'Message not found' });
}
if (existingMsg.senderHash !== auth.senderHash) {
console.warn(`[SECURITY] IDOR attempt: ${auth.senderHash} tried to delete message owned by ${existingMsg.senderHash} (IP: ${getClientIP(req)})`);
return res.status(403).json({ error: 'Forbidden: you can only delete your own messages' });
}
if (existingMsg.roomToken !== auth.roomToken) {
console.warn(`[SECURITY] Cross-room attempt: ${auth.senderHash} tried to delete message in different room (IP: ${getClientIP(req)})`);
return res.status(403).json({ error: 'Forbidden: message belongs to a different room' });
}
writeQueue.push({ action: 'delete', data: { id, senderHash: auth.senderHash } });
const idx = cachedMessages.findIndex(m => m.id === id);
if (idx !== -1) {
cachedMessages.splice(idx, 1);
}
return res.json({ ok: true, queued: writeQueue.length });
} else {
return res.status(400).json({ error: 'Invalid action. Use: add, edit, delete' });
}
});
app.get('/api/status', (_req, res) => {
res.json({
ok: true,
cached: cachedMessages.length,
queued: writeQueue.length,
lastFetch: lastFetchTime ? new Date(lastFetchTime).toISOString() : null,
gistConfigured: !!(GITHUB_TOKEN && GIST_ID),
});
});
// SPA fallback
app.get('*', (_req, res) => {
const indexPath = join(__dirname, 'dist', 'index.html');
if (existsSync(indexPath)) {
res.sendFile(indexPath);
} else {
res.status(200).send(`
<!DOCTYPE html>
<html>
<head><title>AnBiMessenger</title></head>
<body style="background:#0e1621;color:#fff;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
<div style="text-align:center">
<h1>💎 AnBiMessenger</h1>
<p>Build not found. Run <code>npm run build</code> first.</p>
<p style="color:#6b7d8e;font-size:14px">API is running on port ${PORT}</p>
</div>
</body>
</html>
`);
}
});
// ═══════════════════════════════════════════
// START
// ═══════════════════════════════════════════
// Initial fetch
fetchFromGist().then(() => {
console.log('[Init] Initial Gist fetch complete');
}).catch(e => {
console.error('[Init] Initial fetch failed:', e.message);
});
// Background timers
setInterval(fetchFromGist, 4000);
setInterval(flushQueue, 2000);
app.listen(PORT, '0.0.0.0', () => {
console.log('');
console.log(' ╔═══════════════════════════════════════════════╗');
console.log(' ║ 💎 AnBiMessenger Server Started (Hardened) ║');
console.log(' ╠═══════════════════════════════════════════════╣');
console.log(` ║ Port: ${PORT} ║`);
console.log(` ║ GITHUB_TOKEN: ${GITHUB_TOKEN ? '✅ Set' : '❌ Not set'} ║`);
console.log(` ║ GIST_ID: ${GIST_ID ? '✅ Set' : '❌ Not set'} ║`);
console.log(` ║ Shard Size: ${SHARD_SIZE} msgs/shard ║`);
console.log(` ║ Auth: Room Token + IDOR checks ║`);
console.log(` ║ Express: v4 (stable) ║`);
console.log(' ╚═══════════════════════════════════════════════╝');
console.log('');
if (!GITHUB_TOKEN || !GIST_ID) {
console.log(' ⚠️ Set GITHUB_TOKEN and GIST_ID environment variables');
console.log(' for persistent message storage via GitHub Gist.');
console.log('');
}
});