security hardening

This commit is contained in:
2026-04-18 11:10:41 +00:00
parent a409203025
commit 21618efbad
36 changed files with 1455 additions and 283 deletions

View File

@@ -5,16 +5,26 @@
require('../models'); // ensure Ticket model is registered
const express = require('express');
const mongoose = require('mongoose');
const rateLimit = require('express-rate-limit');
const { getBot } = require('../api/bosscordClient');
const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { updateTicketActivity } = require('../services/tickets');
const { enqueueSend } = require('../services/channelQueue');
const { extractRawEmail } = require('../utils');
const { CONFIG } = require('../config');
const router = express.Router();
const Ticket = mongoose.model('Ticket');
const CORS_ORIGIN = process.env.BOSSCORD_CORS_ORIGIN || '*';
const CORS_ORIGIN = process.env.BOSSCORD_CLIENT_ORIGIN || 'http://100.114.205.53:3081';
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' }
});
function corsMiddleware(req, res, next) {
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
@@ -39,6 +49,7 @@ function authMiddleware(req, res, next) {
next();
}
router.use(apiLimiter);
router.use(corsMiddleware);
router.use(authMiddleware);
@@ -178,7 +189,7 @@ router.post('/tickets/:id/messages', express.json(), async (req, res) => {
return res.status(404).json({ error: 'Discord channel not found' });
}
const discordUser = req.body.displayName || 'bOSScord';
await channel.send(content);
await enqueueSend(channel, content);
if (!ticket.gmailThreadId.startsWith('discord-')) {
try {

View File

@@ -1,10 +1,22 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const { ChannelType } = require('discord.js');
const { CONFIG } = require('../config');
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
const { logSystem } = require('../services/debugLog');
const router = express.Router();
const internalLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' }
});
router.use(internalLimiter);
// Middleware: verify internal secret
router.use((req, res, next) => {
const secret = req.headers['x-internal-secret'];
@@ -25,12 +37,77 @@ router.get('/config', (req, res) => {
res.json(obj);
});
// POST /config — apply config updates
// POST /config — apply config updates (allowlisted keys only)
const ALLOWED_CONFIG_KEYS = new Set([
// Ticket settings
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS',
// Escalation categories
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
// Roles and staff
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK',
// Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID',
'STAFF_NOTIFICATION_CATEGORY_ID',
// Pattern channel IDs
'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID',
'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_CHANNEL_ID',
// Messages and labels
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
// Branding
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
// Toggles
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE',
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
'STAFF_DND_COUNTS_AS_AVAILABLE',
// Limits and thresholds
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
// Embed colors
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI',
// Pattern thresholds
'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD',
'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD',
'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES',
// Surge settings
'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES',
'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES',
'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS',
'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS',
'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES',
'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD',
// Chat alerts
'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT',
'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES',
// Notification thresholds
'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS'
]);
router.post('/config', express.json(), async (req, res) => {
const updates = req.body;
if (!updates || typeof updates !== 'object') {
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return res.status(400).json({ error: 'Invalid body' });
}
const rejected = Object.keys(updates).filter(k => !ALLOWED_CONFIG_KEYS.has(k));
if (rejected.length > 0) {
return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` });
}
const result = applyConfigUpdates(updates);
await logSystem('Config updated via settings UI', [
{ name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false },
@@ -50,8 +127,14 @@ router.get('/discord/guild', async (req, res) => {
await guild.members.fetch().catch(() => {});
const CHANNEL_TYPES = [
ChannelType.GuildText,
ChannelType.GuildCategory,
ChannelType.GuildAnnouncement,
ChannelType.GuildForum
];
const channels = guild.channels.cache
.filter(c => [0, 4, 5, 15].includes(c.type))
.filter(c => CHANNEL_TYPES.includes(c.type))
.map(c => ({ id: c.id, name: c.name, type: c.type, parentId: c.parentId }))
.sort((a, b) => a.name.localeCompare(b.name));
@@ -71,7 +154,7 @@ router.get('/discord/guild', async (req, res) => {
.sort((a, b) => a.displayName.localeCompare(b.displayName));
const categories = guild.channels.cache
.filter(c => c.type === 4)
.filter(c => c.type === ChannelType.GuildCategory)
.map(c => ({ id: c.id, name: c.name }))
.sort((a, b) => a.name.localeCompare(b.name));