/** * VYNDR Security Audit Tests * Pure logic tests — inline constants and validation logic. * 45 tests across JWT, input validation, image validation, parlay, * security headers, error handling, real IP, CORS, env check, * security logger, digest, source scan, and migration. */ const fs = require('fs'); const path = require('path'); // --------------------------------------------------------------------------- // Inline helpers (mirrors production logic without importing app code) // --------------------------------------------------------------------------- const JWT_SECRET = 'test-secret-256bit-minimum-length-ok'; const INTERNAL_KEY = 'VYNDR_INTERNAL_abc123'; function parseJwt(token) { try { const parts = token.split('.'); if (parts.length !== 3) return null; const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); return payload; } catch { return null; } } function makeJwt(payload, expiresInSec = 3600) { const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); const now = Math.floor(Date.now() / 1000); const body = Buffer.from(JSON.stringify({ ...payload, iat: now, exp: now + expiresInSec, })).toString('base64url'); const sig = Buffer.from('fake-sig').toString('base64url'); return `${header}.${body}.${sig}`; } function extractBearer(authHeader) { if (!authHeader) return { status: 401, error: 'Missing Authorization header' }; if (!authHeader.startsWith('Bearer ')) return { status: 401, error: 'Malformed Bearer token' }; const token = authHeader.slice(7).trim(); if (!token) return { status: 401, error: 'Empty token' }; const payload = parseJwt(token); if (!payload) return { status: 401, error: 'Invalid token' }; if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null; return payload; } function verifyIssuer(payload, expectedIssuer) { if (!payload || payload.iss !== expectedIssuer) return null; return payload; } function verifyInternalKey(key) { return key === INTERNAL_KEY; } const VALID_STAT_TYPES = [ 'points', 'rebounds', 'assists', 'threes', 'steals', 'blocks', 'turnovers', 'pts_rebs_asts', 'pts_rebs', 'pts_asts', 'rebs_asts', 'strikeouts', 'hits', 'home_runs', 'rbi', 'bases', 'outs', ]; function sanitize(val) { return val.replace(/[;'"\\`]/g, '').replace(/<[^>]+>/g, '').trim().slice(0, 100); } function validateGradeRequest(body) { const errors = []; if (!body.player_name) errors.push('player_name is required'); if (body.stat_type && !VALID_STAT_TYPES.includes(body.stat_type)) { errors.push(`Invalid stat_type: ${body.stat_type}`); } if (body.line !== undefined) { if (body.line > 500) errors.push('Line out of range (max 500)'); if (body.line < 0) errors.push('Line cannot be negative'); } if (body.over_under && !['over', 'under'].includes(body.over_under)) { errors.push('over_under must be "over" or "under"'); } if (body.player_name && body.player_name.length > 100) { errors.push('player_name exceeds max length of 100'); } return errors.length ? { valid: false, errors } : { valid: true }; } function detectSqlInjection(input) { const patterns = [/drop\s+table/i, /;\s*delete/i, /union\s+select/i, /or\s+1\s*=\s*1/i]; return patterns.some((p) => p.test(input)); } function validateImageMagicBytes(buffer) { if (buffer.length < 4) return false; // PNG if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return true; // JPEG if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return true; return false; } function isExecutable(buffer) { if (buffer.length < 2) return false; return buffer[0] === 0x4d && buffer[1] === 0x5a; // MZ header } const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB function validateParlayLegs(legs) { if (!Array.isArray(legs) || legs.length < 2) return { valid: false, error: 'Minimum 2 legs required' }; if (legs.length > 12) return { valid: false, error: 'Maximum 12 legs allowed' }; for (let i = 0; i < legs.length; i++) { if (!legs[i].player_name || !legs[i].stat_type) { return { valid: false, error: `Leg ${i + 1} must have player_name and stat_type` }; } } return { valid: true }; } function getSecurityHeaders() { return { 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', 'Content-Security-Policy': "default-src 'self'", }; } function productionErrorHandler(err) { return { status: err.status || 500, body: { error: 'Internal server error' } }; } function notFoundHandler() { return { status: 404, body: { error: 'Endpoint not found' } }; } function rateLimitHandler() { return { status: 429, body: { error: 'Rate limit exceeded' } }; } function extractRealIp(headers) { if (headers['x-forwarded-for']) { return headers['x-forwarded-for'].split(',')[0].trim(); } return headers.remote_addr || '127.0.0.1'; } function parseAllowedOrigins(envVar) { if (!envVar) return ['http://localhost:3000']; return envVar.split(',').map((o) => o.trim()); } function checkEnvVars(env, required, recommended) { const errors = []; const warnings = []; for (const v of required) { if (!env[v]) errors.push(`Missing required env var: ${v}`); } for (const v of recommended) { if (!env[v]) warnings.push(`Missing recommended env var: ${v}`); } return { errors, warnings, shouldExit: errors.length > 0 }; } function detectSqlInjectionInBody(body) { const str = JSON.stringify(body); return /drop\s+table/i.test(str) || /;\s*delete/i.test(str) || /union\s+select/i.test(str); } const RATE_ABUSE_THRESHOLD = 100; // req per min const RETENTION_DAYS = 90; function buildSecurityDigest(events) { const ipCounts = {}; const typeCounts = {}; for (const e of events) { ipCounts[e.ip_address] = (ipCounts[e.ip_address] || 0) + 1; typeCounts[e.event_type] = (typeCounts[e.event_type] || 0) + 1; } const flaggedIps = Object.entries(ipCounts) .filter(([, c]) => c >= 50) .map(([ip]) => ip); return { flaggedIps, typeCounts }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('JWT Auth', () => { test('1 — valid JWT with Bearer prefix returns payload', () => { const token = makeJwt({ sub: 'user-123', iss: 'vyndr' }); const result = extractBearer(`Bearer ${token}`); expect(result).not.toBeNull(); expect(result.sub).toBe('user-123'); }); test('2 — expired JWT returns null', () => { const token = makeJwt({ sub: 'user-123' }, -100); // expired 100s ago const result = extractBearer(`Bearer ${token}`); expect(result).toBeNull(); }); test('3 — missing Authorization header returns 401 shape', () => { const result = extractBearer(undefined); expect(result).toEqual({ status: 401, error: 'Missing Authorization header' }); }); test('4 — malformed Bearer token (no space) returns 401 shape', () => { const result = extractBearer('Bearertoken123'); expect(result).toEqual({ status: 401, error: 'Malformed Bearer token' }); }); test('5 — empty token returns 401 shape', () => { const result = extractBearer('Bearer '); expect(result).toEqual({ status: 401, error: 'Empty token' }); }); test('6 — JWT issuer verification rejects wrong issuer', () => { const payload = { sub: 'user-123', iss: 'other-service' }; const result = verifyIssuer(payload, 'vyndr'); expect(result).toBeNull(); }); test('7 — internal key auth passes with valid key', () => { expect(verifyInternalKey(INTERNAL_KEY)).toBe(true); expect(verifyInternalKey('wrong-key')).toBe(false); }); }); describe('Input Validation', () => { test('8 — valid grade request passes validation', () => { const result = validateGradeRequest({ player_name: 'LeBron James', stat_type: 'points', line: 25.5, over_under: 'over', }); expect(result.valid).toBe(true); }); test('9 — missing player_name returns error', () => { const result = validateGradeRequest({ stat_type: 'points', line: 25.5 }); expect(result.valid).toBe(false); expect(result.errors).toContain('player_name is required'); }); test('10 — invalid stat_type returns error', () => { const result = validateGradeRequest({ player_name: 'LeBron', stat_type: 'fake_stat' }); expect(result.valid).toBe(false); expect(result.errors[0]).toMatch(/Invalid stat_type/); }); test('11 — line out of range (>500) returns error', () => { const result = validateGradeRequest({ player_name: 'LeBron', line: 501 }); expect(result.valid).toBe(false); expect(result.errors).toContain('Line out of range (max 500)'); }); test('12 — negative line returns error', () => { const result = validateGradeRequest({ player_name: 'LeBron', line: -5 }); expect(result.valid).toBe(false); expect(result.errors).toContain('Line cannot be negative'); }); test('13 — over_under must be over or under', () => { const result = validateGradeRequest({ player_name: 'LeBron', over_under: 'sideways' }); expect(result.valid).toBe(false); expect(result.errors[0]).toMatch(/over_under/); }); test('14 — SQL injection chars stripped from player_name', () => { expect(sanitize("LeBron'; DROP TABLE users;--")).toBe('LeBron DROP TABLE users--'); }); test('15 — HTML tags stripped from input', () => { expect(sanitize('LeBronJames')).toBe('LeBronalert(1)James'); }); test('16 — player name max length 100 enforced', () => { const long = 'A'.repeat(200); expect(sanitize(long).length).toBe(100); }); test('17 — SQL injection pattern "drop table" detected', () => { expect(detectSqlInjection('DROP TABLE users')).toBe(true); expect(detectSqlInjection('LeBron James')).toBe(false); }); }); describe('Image Validation', () => { test('18 — PNG magic bytes accepted', () => { const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); expect(validateImageMagicBytes(buf)).toBe(true); }); test('19 — JPEG magic bytes accepted', () => { const buf = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); expect(validateImageMagicBytes(buf)).toBe(true); }); test('20 — executable file (MZ header) rejected', () => { const buf = Buffer.from([0x4d, 0x5a, 0x90, 0x00]); expect(validateImageMagicBytes(buf)).toBe(false); expect(isExecutable(buf)).toBe(true); }); test('21 — oversized file (>10MB) rejected', () => { const size = 11 * 1024 * 1024; expect(size > MAX_IMAGE_SIZE).toBe(true); }); }); describe('Parlay Validation', () => { test('22 — minimum 2 legs required', () => { const result = validateParlayLegs([{ player_name: 'LeBron', stat_type: 'points' }]); expect(result.valid).toBe(false); expect(result.error).toMatch(/Minimum 2 legs/); }); test('23 — maximum 12 legs enforced', () => { const legs = Array.from({ length: 13 }, () => ({ player_name: 'X', stat_type: 'points' })); const result = validateParlayLegs(legs); expect(result.valid).toBe(false); expect(result.error).toMatch(/Maximum 12 legs/); }); test('24 — each leg must have player_name and stat_type', () => { const legs = [ { player_name: 'LeBron', stat_type: 'points' }, { player_name: 'Steph' }, // missing stat_type ]; const result = validateParlayLegs(legs); expect(result.valid).toBe(false); expect(result.error).toMatch(/Leg 2 must have/); }); }); describe('Security Headers', () => { const headers = getSecurityHeaders(); test('25 — X-Frame-Options is DENY', () => { expect(headers['X-Frame-Options']).toBe('DENY'); }); test('26 — X-Content-Type-Options is nosniff', () => { expect(headers['X-Content-Type-Options']).toBe('nosniff'); }); test('27 — Strict-Transport-Security present', () => { expect(headers['Strict-Transport-Security']).toBeDefined(); expect(headers['Strict-Transport-Security']).toMatch(/max-age=/); }); test('28 — Content-Security-Policy present', () => { expect(headers['Content-Security-Policy']).toBeDefined(); expect(headers['Content-Security-Policy']).toContain("default-src"); }); }); describe('Error Handling', () => { test('29 — production error handler returns generic message (no stack trace)', () => { const err = new Error('secret DB password in stack'); err.status = 500; const response = productionErrorHandler(err); expect(response.body.error).toBe('Internal server error'); expect(JSON.stringify(response.body)).not.toContain('stack'); expect(JSON.stringify(response.body)).not.toContain('secret DB password'); }); test('30 — 404 returns JSON with Endpoint not found', () => { const response = notFoundHandler(); expect(response.status).toBe(404); expect(response.body.error).toBe('Endpoint not found'); }); test('31 — 429 returns JSON with Rate limit exceeded', () => { const response = rateLimitHandler(); expect(response.status).toBe(429); expect(response.body.error).toBe('Rate limit exceeded'); }); }); describe('Real IP Extraction', () => { test('32 — X-Forwarded-For first IP extracted correctly', () => { const ip = extractRealIp({ 'x-forwarded-for': '203.0.113.50, 70.41.3.18, 150.172.238.178' }); expect(ip).toBe('203.0.113.50'); }); test('33 — falls back to remote_addr when no forwarded header', () => { const ip = extractRealIp({ remote_addr: '10.0.0.1' }); expect(ip).toBe('10.0.0.1'); }); }); describe('CORS', () => { test('34 — ALLOWED_ORIGINS parsed from comma-separated env var', () => { const origins = parseAllowedOrigins('https://vyndr.app, https://app.vyndr.app'); expect(origins).toEqual(['https://vyndr.app', 'https://app.vyndr.app']); }); test('35 — default origin is localhost:3000', () => { const origins = parseAllowedOrigins(undefined); expect(origins).toEqual(['http://localhost:3000']); }); }); describe('Environment Check', () => { test('36 — missing required var flagged', () => { const result = checkEnvVars({}, ['DATABASE_URL', 'JWT_SECRET'], []); expect(result.errors.length).toBe(2); expect(result.shouldExit).toBe(true); }); test('37 — missing recommended var warns but does not exit', () => { const result = checkEnvVars( { DATABASE_URL: 'postgres://...' }, ['DATABASE_URL'], ['SENTRY_DSN'], ); expect(result.errors.length).toBe(0); expect(result.warnings.length).toBe(1); expect(result.shouldExit).toBe(false); }); }); describe('Security Logger', () => { test('38 — SQL injection pattern detected in request body', () => { const body = { player_name: "LeBron'; DROP TABLE users;--" }; expect(detectSqlInjectionInBody(body)).toBe(true); }); test('39 — rate abuse threshold is 100 req/min', () => { expect(RATE_ABUSE_THRESHOLD).toBe(100); }); test('40 — security event cleanup uses 90-day retention', () => { expect(RETENTION_DAYS).toBe(90); }); }); describe('Security Digest', () => { test('41 — digest flags IPs with 50+ events', () => { const events = Array.from({ length: 55 }, (_, i) => ({ ip_address: '203.0.113.50', event_type: 'sql_injection', })); events.push({ ip_address: '10.0.0.1', event_type: 'rate_limit' }); const digest = buildSecurityDigest(events); expect(digest.flaggedIps).toContain('203.0.113.50'); expect(digest.flaggedIps).not.toContain('10.0.0.1'); }); test('42 — digest counts events by type', () => { const events = [ { ip_address: '1.1.1.1', event_type: 'sql_injection' }, { ip_address: '1.1.1.1', event_type: 'sql_injection' }, { ip_address: '2.2.2.2', event_type: 'rate_limit' }, ]; const digest = buildSecurityDigest(events); expect(digest.typeCounts.sql_injection).toBe(2); expect(digest.typeCounts.rate_limit).toBe(1); }); }); describe('Source Code Scan', () => { test('43 — no sk_live_ found in any source file', () => { function scan(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const e of entries) { if (['node_modules', '.git', '__pycache__', '.next'].includes(e.name)) continue; const full = path.join(dir, e.name); if (e.isDirectory()) scan(full); else if (/\.(py|js|ts|json|yml)$/.test(e.name)) { const content = fs.readFileSync(full, 'utf8'); expect(content).not.toContain('sk_live_'); } } } scan(path.join(__dirname, '../../src')); }); test('44 — .gitignore includes critical patterns', () => { const gitignore = fs.readFileSync(path.join(__dirname, '../../.gitignore'), 'utf8'); expect(gitignore).toContain('.env'); expect(gitignore).toContain('.env.local'); expect(gitignore).toContain('.env.production'); expect(gitignore).toContain('*.pem'); expect(gitignore).toContain('*.key'); }); }); describe('Migration 010', () => { test('45 — creates security_events table with RLS', () => { const sql = fs.readFileSync( path.join(__dirname, '../../supabase/migrations/010_security_events.sql'), 'utf8', ); expect(sql).toContain('CREATE TABLE'); expect(sql).toContain('security_events'); expect(sql).toContain('ENABLE ROW LEVEL SECURITY'); expect(sql).toContain('event_type'); expect(sql).toContain('ip_address'); expect(sql).toContain('created_at'); }); });