diff --git a/database/auth.js b/database/auth.js
new file mode 100644
index 0000000..47bd445
--- /dev/null
+++ b/database/auth.js
@@ -0,0 +1,34 @@
+// Shared GM authorization middleware
+// Authorizes based on server-side session lookup via x-session-id header.
+// Does NOT trust x-gm-secret header (that was the vulnerability).
+//
+// Usage in async route handlers:
+// const { requireGM } = require('../auth');
+// if (!(await requireGM(req, res))) return;
+//
+// Usage in sync middleware (not supported — this is async only).
+
+const { validateSession } = require('./sessionModel');
+
+async function requireGM(req, res) {
+ const sessionId = req.headers['x-session-id'];
+ if (!sessionId) {
+ res.status(401).json({ error: 'Authentication required' });
+ return false;
+ }
+
+ const session = await validateSession(sessionId);
+ if (!session || !session.data || !session.data.playerName) {
+ res.status(401).json({ error: 'Invalid or expired session' });
+ return false;
+ }
+
+ if (session.data.playerName.toLowerCase() !== 'gm') {
+ res.status(403).json({ error: 'GM access required' });
+ return false;
+ }
+
+ return true;
+}
+
+module.exports = { requireGM };
\ No newline at end of file
diff --git a/database/routes/bestiaryRoutes.js b/database/routes/bestiaryRoutes.js
index 82e7703..531306c 100644
--- a/database/routes/bestiaryRoutes.js
+++ b/database/routes/bestiaryRoutes.js
@@ -2,6 +2,7 @@ const express = require('express');
const fs = require('fs');
const path = require('path');
const { bestiaryHelpers, logToFile } = require('../mariadb');
+const { requireGM } = require('../auth');
const router = express.Router();
const BESTIARY_FALLBACK_PATH = path.join(__dirname, '..', '..', 'public', 'deathwatch-bestiary-extracted.json');
@@ -195,15 +196,10 @@ router.get('/stats', async (req, res) => {
}
});
-// Force reload bestiary data (admin only)
+// Force reload bestiary data (GM only)
router.post('/reload', async (req, res) => {
try {
- // Check for GM secret
- const gmSecret = req.headers['x-gm-secret'];
- const expectedGmSecret = process.env.GM_SECRET || 'defaultsecret';
- if (gmSecret !== expectedGmSecret) {
- return res.status(403).json({ error: 'Unauthorized' });
- }
+ if (!(await requireGM(req, res))) return;
// No cache to reset since we query MariaDB directly
const entries = await loadBestiaryData();
diff --git a/database/routes/playerRoutes.js b/database/routes/playerRoutes.js
index ed2d369..8790ce7 100644
--- a/database/routes/playerRoutes.js
+++ b/database/routes/playerRoutes.js
@@ -1,6 +1,8 @@
const express = require('express');
+const crypto = require('crypto');
const bcrypt = require('bcrypt');
const { playerHelpers, logToFile } = require('../mariadb');
+const { requireGM } = require('../auth');
const router = express.Router();
async function isValidPlayerPassword(player, password) {
@@ -41,8 +43,8 @@ router.post('/login', async (req, res) => {
}
}
- // Generate a session ID with expiration
- const sessionId = `session_${name}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ // Generate a cryptographically random session ID (no player name, no timestamp)
+ const sessionId = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Store session in database with expiration
@@ -83,8 +85,8 @@ router.get('/', async (req, res) => {
const players = await playerHelpers.getAll();
logToFile('API: Fetch all players (public)', players.length);
- // If authenticated (has session header or x-gm-secret), return full player data
- const hasSession = req.headers['x-session-id'] || req.headers['x-gm-secret'];
+ // If authenticated (has session header), return full player data
+ const hasSession = req.headers['x-session-id'];
if (hasSession) {
// Return full player data for authenticated requests
@@ -203,16 +205,7 @@ router.post('/', async (req, res) => {
}
});
-// --- GM Endpoints (require x-gm-secret header) ---
-
-function requireGM(req, res) {
- const gmSecret = process.env.GM_SECRET || 'bongo';
- if (req.headers['x-gm-secret'] !== gmSecret) {
- res.status(403).json({ error: 'Unauthorized' });
- return false;
- }
- return true;
-}
+// --- GM Endpoints (require server-side session with playerName === 'gm') ---
// Helper: update a field inside tab_info JSON
async function updateTabInfo(name, fields) {
@@ -231,7 +224,7 @@ async function updateTabInfo(name, fields) {
// Set XP for a player
router.post('/gm/set-xp', async (req, res) => {
- if (!requireGM(req, res)) return;
+ if (!(await requireGM(req, res))) return;
try {
const { playerName, xp } = req.body;
const ok = await updateTabInfo(playerName, { xp: parseInt(xp) || 0 });
@@ -246,7 +239,7 @@ router.post('/gm/set-xp', async (req, res) => {
// Set XP Spent for a player
router.post('/gm/set-xp-spent', async (req, res) => {
- if (!requireGM(req, res)) return;
+ if (!(await requireGM(req, res))) return;
try {
const { playerName, xpSpent } = req.body;
const ok = await updateTabInfo(playerName, { xpSpent: parseInt(xpSpent) || 0 });
@@ -261,7 +254,7 @@ router.post('/gm/set-xp-spent', async (req, res) => {
// Set Requisition Points for a player
router.post('/gm/set-rp', async (req, res) => {
- if (!requireGM(req, res)) return;
+ if (!(await requireGM(req, res))) return;
try {
const { playerName, requisitionPoints } = req.body;
const ok = await updateTabInfo(playerName, { rp: parseInt(requisitionPoints) || 0 });
@@ -276,7 +269,7 @@ router.post('/gm/set-rp', async (req, res) => {
// Set Renown for a player
router.post('/gm/set-renown', async (req, res) => {
- if (!requireGM(req, res)) return;
+ if (!(await requireGM(req, res))) return;
try {
const { playerName, renown } = req.body;
const ok = await updateTabInfo(playerName, { renown });
@@ -291,7 +284,7 @@ router.post('/gm/set-renown', async (req, res) => {
// Add or update a player (create if not exists, set RP)
router.post('/gm/add-or-update', async (req, res) => {
- if (!requireGM(req, res)) return;
+ if (!(await requireGM(req, res))) return;
try {
const { name, requisitionPoints, password, charName } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
@@ -325,7 +318,7 @@ router.post('/gm/add-or-update', async (req, res) => {
// Reset player password
router.post('/gm/reset-password', async (req, res) => {
- if (!requireGM(req, res)) return;
+ if (!(await requireGM(req, res))) return;
try {
const { playerName, newPassword } = req.body;
const player = await playerHelpers.getByName(playerName);
@@ -347,7 +340,7 @@ router.post('/gm/reset-password', async (req, res) => {
// Delete player (GM)
router.delete('/gm/delete/:name', async (req, res) => {
- if (!requireGM(req, res)) return;
+ if (!(await requireGM(req, res))) return;
try {
const { name } = req.params;
const success = await playerHelpers.delete(name);
@@ -382,4 +375,4 @@ router.delete('/:name', async (req, res) => {
});
console.log('Player routes registered (MariaDB)');
-module.exports = router;
+module.exports = router;
\ No newline at end of file
diff --git a/database/routes/rulesRoutes.js b/database/routes/rulesRoutes.js
index 58cf8c6..78b2385 100644
--- a/database/routes/rulesRoutes.js
+++ b/database/routes/rulesRoutes.js
@@ -2,6 +2,7 @@ const express = require('express');
const fs = require('fs');
const path = require('path');
const { rulesHelpers, logToFile } = require('../mariadb');
+const { requireGM } = require('../auth');
const router = express.Router();
console.log('Rules routes registered (MariaDB)');
@@ -308,12 +309,10 @@ router.get('/stats', async (req, res) => {
}
});
-// Reload the rules database (admin only)
+// Reload the rules database (GM only)
router.post('/reload', async (req, res) => {
try {
- const gmSecret = req.headers['x-gm-secret'];
- const expectedGmSecret = process.env.GM_SECRET || 'defaultsecret';
- if (gmSecret !== expectedGmSecret) return res.status(403).json({ error: 'Unauthorized' });
+ if (!(await requireGM(req, res))) return;
const allRules = await getAllRules();
const totalRules = allRules.length;
@@ -327,9 +326,7 @@ router.post('/reload', async (req, res) => {
// ── Dedup rules (GM-only) ─────────────────────────────────────────────────────
router.post('/admin/tag-and-dedup', async (req, res) => {
- const gmSecret = req.headers['x-gm-secret'];
- if (gmSecret !== (process.env.GM_SECRET || 'defaultsecret'))
- return res.status(403).json({ error: 'Unauthorized' });
+ if (!(await requireGM(req, res))) return;
const { pool } = require('../mariadb');
diff --git a/database/server.js b/database/server.js
index e6eba8e..53418e3 100755
--- a/database/server.js
+++ b/database/server.js
@@ -151,12 +151,11 @@ try {
console.error('Error mounting /api/gmkit or /gmkit:', e && e.stack ? e.stack : e);
}
- // Simple JSON base64 upload endpoint for GM Kit files (GM-only via header)
- app.post('/api/gmkit/upload', express.json({ limit: '20mb' }), (req, res) => {
+ // Simple JSON base64 upload endpoint for GM Kit files (GM-only via session)
+ const { requireGM } = require('./auth');
+ app.post('/api/gmkit/upload', express.json({ limit: '20mb' }), async (req, res) => {
try {
- const gmSecret = req.headers['x-gm-secret'];
- const expectedGmSecret = process.env.GM_SECRET || 'defaultsecret';
- if (gmSecret !== expectedGmSecret) return res.status(403).json({ error: 'Unauthorized' });
+ if (!(await requireGM(req, res))) return;
const { name, b64 } = req.body || {};
if (!name || !b64) return res.status(400).json({ error: 'Missing name or b64 body' });
if (!fs.existsSync(gmkitDir)) fs.mkdirSync(gmkitDir, { recursive: true });
diff --git a/src/App.js b/src/App.js
index 45aa03f..8eeba47 100755
--- a/src/App.js
+++ b/src/App.js
@@ -422,7 +422,7 @@ function App() {
)}
-{tab==='roller' ?
The Bestiary is only accessible to Game Masters. Please log in with a GM account.
The Bestiary is only accessible to Game Masters. Please log in with a GM account.