510 lines
17 KiB
JavaScript
510 lines
17 KiB
JavaScript
/**
|
|
* 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('LeBron<script>alert(1)</script>James')).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');
|
|
});
|
|
});
|