randomayzer/tests/oauth-security-gate.test.ts

89 lines
3.6 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
import { defaultOAuthTransactionStore } from '../src/lib/auth/oauth-state';
import { validateSafeRedirectTarget } from '../src/lib/auth/safe-redirect';
import { GET as startGet } from '../src/app/api/auth/vk/start/route';
import { GET as callbackGet } from '../src/app/api/auth/vk/callback/route';
import { POST as logoutPost } from '../src/app/api/auth/logout/route';
describe('Phase 2.2.1 OAuth Security Gate, Redirects & CSRF Protection', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
defaultOAuthTransactionStore.clear();
});
afterEach(() => {
process.env = originalEnv;
});
it('validates and neutralizes dangerous redirect targets (open redirect prevention)', () => {
// Dangerous attacks
expect(validateSafeRedirectTarget('//evil.com/phishing')).toBe('/');
expect(validateSafeRedirectTarget('/\\evil.com')).toBe('/');
expect(validateSafeRedirectTarget('https://attacker.com')).toBe('/');
expect(validateSafeRedirectTarget('http://attacker.com')).toBe('/');
expect(validateSafeRedirectTarget('javascript:alert(1)')).toBe('/');
expect(validateSafeRedirectTarget('data:text/html,<script>evil()</script>')).toBe('/');
expect(validateSafeRedirectTarget(' ')).toBe('/');
expect(validateSafeRedirectTarget(null)).toBe('/');
// Legitimate local paths
expect(validateSafeRedirectTarget('/giveaways/new')).toBe('/giveaways/new');
expect(validateSafeRedirectTarget('/giveaways/123')).toBe('/giveaways/123');
expect(validateSafeRedirectTarget('/')).toBe('/');
});
it('invalidates state transaction when OAuth is cancelled (error=access_denied)', async () => {
const { state } = await defaultOAuthTransactionStore.createTransaction();
const req = new NextRequest(
`http://localhost:3000/api/auth/vk/callback?error=access_denied&error_description=User%20denied&state=${state}`
);
const res = await callbackGet(req);
expect(res.status).toBe(307);
// Attempting to reuse the state MUST fail
await expect(defaultOAuthTransactionStore.consumeTransaction(state)).rejects.toThrow();
});
it('sanitizes external redirectTarget on OAuth start', async () => {
const req = new NextRequest('http://localhost:3000/api/auth/vk/start?redirectTarget=//evil.com/attack');
const res = await startGet(req);
expect(res.status).toBe(307);
const location = res.headers.get('location');
expect(location).toContain('https://id.vk.com/auth');
// Location URL must not contain evil.com redirect
expect(location).not.toContain('evil.com');
});
it('rejects POST /api/auth/logout with cross-site Origin / CSRF mismatch', async () => {
// 1. Cross-site Sec-Fetch-Site
const crossSiteReq = new NextRequest('http://localhost:3000/api/auth/logout', {
method: 'POST',
headers: {
'sec-fetch-site': 'cross-site',
},
});
const crossSiteRes = await logoutPost(crossSiteReq);
expect(crossSiteRes.status).toBe(403);
const body1 = await crossSiteRes.json();
expect(body1.error?.message).toMatch(/CSRF/i);
// 2. Mismatched Origin
const mismatchReq = new NextRequest('http://localhost:3000/api/auth/logout', {
method: 'POST',
headers: {
origin: 'https://evil-hacker.com',
host: 'localhost:3000',
},
});
const mismatchRes = await logoutPost(mismatchReq);
expect(mismatchRes.status).toBe(403);
const body2 = await mismatchRes.json();
expect(body2.error?.message).toMatch(/CSRF/i);
});
});