From 36b9f802a101799249de6560379b406a8516d95b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Z=C3=BCri=20Bar=20Yochay?= <7790+zuriby@users.noreply.github.com> Date: Sun, 8 Jun 2025 14:27:13 +0400 Subject: [PATCH 1/2] Create more_tests.ts --- __tests__/more_tests.ts | 676 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 676 insertions(+) create mode 100644 __tests__/more_tests.ts diff --git a/__tests__/more_tests.ts b/__tests__/more_tests.ts new file mode 100644 index 0000000..88faf7b --- /dev/null +++ b/__tests__/more_tests.ts @@ -0,0 +1,676 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { unstable_dev } from 'wrangler'; +import type { UnstableDevWorker } from 'wrangler'; + +describe('OAuth Provider Security Tests', () => { + let worker: UnstableDevWorker; + + beforeAll(async () => { + worker = await unstable_dev('src/index.ts', { + experimental: { disableExperimentalWarning: true }, + }); + }); + + afterAll(async () => { + await worker.stop(); + }); + + describe('RFC 6749 MUST Requirements', () => { + describe('Section 2.3.1 - Client Authentication', () => { + it('MUST support HTTP Basic authentication scheme for clients', async () => { + const clientId = 'test-client'; + const clientSecret = 'test-secret'; + const credentials = btoa(`${clientId}:${clientSecret}`); + + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Authorization': `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=authorization_code&code=invalid', + }); + + // Should fail for invalid code, not auth + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).not.toBe('invalid_client'); + }); + + it('MUST URL-encode client credentials in Basic auth per RFC 6749', async () => { + // Test special characters that need encoding + const clientId = 'client@example.com'; + const clientSecret = 'secret:with:colons'; + const encodedId = encodeURIComponent(clientId); + const encodedSecret = encodeURIComponent(clientSecret); + const credentials = btoa(`${encodedId}:${encodedSecret}`); + + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Authorization': `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=authorization_code&code=invalid', + }); + + // Should process auth correctly despite special chars + expect(response.status).toBe(401); // No such client + }); + }); + + describe('Section 3.1 - Authorization Endpoint', () => { + it('MUST support the response_type parameter', async () => { + const response = await parseAuthRequest({ + // Missing response_type + client_id: 'test', + redirect_uri: 'https://example.com/callback', + }); + + expect(response).toThrow('response_type parameter is required'); + }); + + it('MUST validate redirect_uri against registered URIs', async () => { + const response = await parseAuthRequest({ + response_type: 'code', + client_id: 'registered-client', + redirect_uri: 'https://evil.com/callback', // Not registered + }); + + expect(response).toThrow('Invalid redirect URI'); + }); + }); + + describe('Section 4.1.3 - Access Token Request', () => { + it('MUST require client authentication for confidential clients', async () => { + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=authorization_code&code=valid-code&client_id=confidential-client', + // Missing client_secret + }); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe('invalid_client'); + }); + + it('MUST invalidate authorization code after single use', async () => { + const code = 'test-auth-code'; + + // First use should succeed + const response1 = await exchangeCode(code); + expect(response1.status).toBe(200); + + // Second use should fail + const response2 = await exchangeCode(code); + expect(response2.status).toBe(400); + const body = await response2.json(); + expect(body.error).toBe('invalid_grant'); + }); + + it('MUST reject authorization codes after expiration', async () => { + const expiredCode = 'expired-auth-code'; + // Wait 11 minutes (codes expire in 10) + await new Promise(resolve => setTimeout(resolve, 11 * 60 * 1000)); + + const response = await exchangeCode(expiredCode); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe('invalid_grant'); + }); + }); + + describe('Section 5.1 - Access Token Response', () => { + it('MUST include token_type in successful response', async () => { + const response = await exchangeCode('valid-code'); + const body = await response.json(); + + expect(body.token_type).toBe('bearer'); + }); + + it('MUST include expires_in for access tokens', async () => { + const response = await exchangeCode('valid-code'); + const body = await response.json(); + + expect(body.expires_in).toBeTypeOf('number'); + expect(body.expires_in).toBeGreaterThan(0); + }); + }); + + describe('Section 5.2 - Error Response', () => { + it('MUST use 400 Bad Request for invalid_request errors', async () => { + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: '', // Missing required parameters + }); + + expect(response.status).toBe(400); + }); + + it('MUST use 401 Unauthorized for invalid_client errors', async () => { + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Authorization': 'Basic ' + btoa('invalid:wrong'), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=authorization_code&code=test', + }); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe('invalid_client'); + }); + }); + + describe('Section 10.10 - PKCE', () => { + it('MUST reject authorization without code_verifier when PKCE was used', async () => { + // Authorization used PKCE + const grant = await completeAuthWithPKCE({ + code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + code_challenge_method: 'S256', + }); + + // Token exchange without code_verifier + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `grant_type=authorization_code&code=${grant.code}`, + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe('invalid_request'); + expect(body.error_description).toContain('code_verifier'); + }); + + it('MUST validate code_verifier against code_challenge', async () => { + const codeVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + const codeChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + + const grant = await completeAuthWithPKCE({ + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + + // Try with wrong verifier + const response = await exchangeCodeWithPKCE(grant.code, 'wrong-verifier'); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe('invalid_grant'); + }); + }); + }); + + describe('RFC 6749 MUST NOT Requirements', () => { + describe('Section 3.2 - Token Endpoint', () => { + it('MUST NOT use GET for token endpoint', async () => { + const response = await worker.fetch('/token', { + method: 'GET', + }); + + expect(response.status).toBe(405); + }); + }); + + describe('Section 4.1.2 - Authorization Response', () => { + it('MUST NOT include authorization code in URL fragment', async () => { + const result = await completeAuthorization({ + response_type: 'code', + redirect_uri: 'https://example.com/callback', + }); + + const url = new URL(result.redirectTo); + expect(url.hash).toBe(''); + expect(url.searchParams.has('code')).toBe(true); + }); + }); + + describe('Section 10.12 - Cross-Site Request Forgery', () => { + it('MUST NOT accept authorization codes across different clients', async () => { + const codeForClientA = await getAuthCodeForClient('client-a'); + + // Try to use client A's code with client B's credentials + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Authorization': 'Basic ' + btoa('client-b:secret-b'), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `grant_type=authorization_code&code=${codeForClientA}`, + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe('invalid_grant'); + }); + }); + }); + + describe('Abuse Cases and Security Edge Cases', () => { + describe('Token Generation Bias Attack', () => { + it('should generate tokens with uniform distribution', async () => { + const tokens = []; + for (let i = 0; i < 10000; i++) { + const token = generateRandomString(32); + tokens.push(token); + } + + // Check character distribution + const charCounts = new Map(); + for (const token of tokens) { + for (const char of token) { + charCounts.set(char, (charCounts.get(char) || 0) + 1); + } + } + + // All characters should appear with roughly equal frequency + const counts = Array.from(charCounts.values()); + const mean = counts.reduce((a, b) => a + b) / counts.length; + const variance = counts.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / counts.length; + const stdDev = Math.sqrt(variance); + + // Standard deviation should be small relative to mean + expect(stdDev / mean).toBeLessThan(0.1); + }); + }); + + describe('Authorization Code Injection', () => { + it('should bind authorization codes to specific redirect URIs', async () => { + const code = await getAuthCode({ + redirect_uri: 'https://example.com/callback', + }); + + // Try to exchange with different redirect_uri + const response = await exchangeCode(code, { + redirect_uri: 'https://example.com/different', + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe('invalid_grant'); + }); + }); + + describe('Token Substitution Attack', () => { + it('should not allow access tokens from one grant to access another', async () => { + const token1 = await getAccessToken({ userId: 'user1' }); + const token2 = await getAccessToken({ userId: 'user2' }); + + // Extract grantId from token2, try to use with token1's secret + const [_, grantId2] = token2.split(':'); + const [userId1, _, secret1] = token1.split(':'); + const forgedToken = `${userId1}:${grantId2}:${secret1}`; + + const response = await worker.fetch('/api/protected', { + headers: { + 'Authorization': `Bearer ${forgedToken}`, + }, + }); + + expect(response.status).toBe(401); + }); + }); + + describe('Refresh Token Hijacking', () => { + it('should invalidate refresh tokens if used from different IP', async () => { + const { refresh_token } = await getTokens(); + + // First refresh from IP1 + const response1 = await refreshToken(refresh_token, { + headers: { 'CF-Connecting-IP': '1.2.3.4' }, + }); + expect(response1.status).toBe(200); + + // Attempt refresh from different IP with same token + const response2 = await refreshToken(refresh_token, { + headers: { 'CF-Connecting-IP': '5.6.7.8' }, + }); + + // Should detect anomaly and revoke grant + expect(response2.status).toBe(400); + }); + }); + + describe('Client Impersonation', () => { + it('should not allow public clients to impersonate confidential clients', async () => { + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=authorization_code&code=test&client_id=confidential-client', + // Attempting to use confidential client without secret + }); + + expect(response.status).toBe(401); + }); + }); + + describe('Timing Attacks', () => { + it('should use constant-time comparison for secrets', async () => { + const timings = []; + + // Test with increasingly similar secrets + const secrets = [ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbba', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ]; + + for (const secret of secrets) { + const start = performance.now(); + await worker.fetch('/token', { + method: 'POST', + headers: { + 'Authorization': 'Basic ' + btoa(`client:${secret}`), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=authorization_code&code=test', + }); + const end = performance.now(); + timings.push(end - start); + } + + // Timings should not correlate with similarity + const variance = Math.var(timings); + expect(variance).toBeLessThan(1); // Less than 1ms variance + }); + }); + + describe('Resource Exhaustion', () => { + it('should rate limit token requests per client', async () => { + const promises = []; + + // Attempt 100 rapid requests + for (let i = 0; i < 100; i++) { + promises.push(exchangeCode(`code-${i}`)); + } + + const responses = await Promise.all(promises); + const tooManyRequests = responses.filter(r => r.status === 429); + + expect(tooManyRequests.length).toBeGreaterThan(0); + }); + + it('should limit number of active tokens per grant', async () => { + const { refresh_token } = await getTokens(); + const tokens = []; + + // Try to create many access tokens + for (let i = 0; i < 100; i++) { + const response = await refreshToken(refresh_token); + if (response.ok) { + const body = await response.json(); + tokens.push(body.access_token); + } + } + + // Should limit tokens per grant + expect(tokens.length).toBeLessThan(10); + }); + }); + + describe('Malformed Input Handling', () => { + it('should safely handle extremely long input', async () => { + const longString = 'a'.repeat(1000000); // 1MB string + + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `grant_type=authorization_code&code=${longString}`, + }); + + expect(response.status).toBe(413); // Payload too large + }); + + it('should handle null bytes in input', async () => { + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=authorization_code&code=test\x00injected', + }); + + expect(response.status).toBe(400); + }); + + it('should reject non-UTF8 input', async () => { + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: Buffer.from([0xFF, 0xFE, 0xFD]), // Invalid UTF-8 + }); + + expect(response.status).toBe(400); + }); + }); + + describe('Cache Poisoning', () => { + it('should include appropriate cache headers', async () => { + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=authorization_code&code=test', + }); + + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(response.headers.get('Pragma')).toBe('no-cache'); + }); + }); + + describe('Open Redirect Protection', () => { + it('should reject redirect URIs with dangerous schemes', async () => { + const dangerousUris = [ + 'javascript:alert(1)', + 'data:text/html,', + 'file:///etc/passwd', + 'about:blank', + 'vbscript:alert(1)', + ]; + + for (const uri of dangerousUris) { + const response = await completeAuthorization({ + redirect_uri: uri, + }); + + expect(response).toThrow('Invalid redirect URI'); + } + }); + + it('should validate redirect URI host strictly', async () => { + // Register client with specific redirect URI + const client = await createClient({ + redirect_uris: ['https://example.com/callback'], + }); + + // Attempt authorization with subdomain + const response = await parseAuthRequest({ + client_id: client.client_id, + redirect_uri: 'https://evil.example.com/callback', + }); + + expect(response).toThrow('Invalid redirect URI'); + }); + }); + + describe('JSON Injection', () => { + it('should safely handle special characters in JSON responses', async () => { + const response = await worker.fetch('/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=authorization_code&code=test', + }); + + const text = await response.text(); + // Ensure < and > are escaped in JSON + expect(text).not.toContain('