Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user