1
0
Fork 0
mirror of https://github.com/wallabag/wallabag.git synced 2025-09-30 19:22:12 +00:00
wallabag/tests/Controller/Api/OAuthSqlInjectionTest.php

463 lines
19 KiB
PHP
Raw Normal View History

<?php
namespace Tests\Wallabag\Controller\Api;
use Doctrine\ORM\EntityManagerInterface;
use Tests\Wallabag\WallabagTestCase;
use Wallabag\Entity\Api\AuthCode;
use Wallabag\Entity\Api\Client;
use Wallabag\Entity\User;
/**
* SQL injection security tests for OAuth endpoints.
*
* Tests various SQL injection attack vectors against OAuth parameters
* to ensure proper parameter sanitization and prepared statement usage.
*/
class OAuthSqlInjectionTest extends WallabagTestCase
{
/**
* Test SQL injection attempts in authorization code parameter.
*/
public function testSqlInjectionInAuthorizationCode(): void
{
$client = $this->getTestClient();
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
$sqlInjectionPayloads = [
// Basic SQL injection attempts
"'; DROP TABLE oauth2_access_tokens; --",
"' OR '1'='1",
"' UNION SELECT * FROM users --",
"'; DELETE FROM oauth2_clients WHERE id=1; --",
// Advanced SQL injection attempts
"' OR 1=1 UNION SELECT username, password FROM users --",
"'; INSERT INTO oauth2_access_tokens VALUES (1, 'malicious', 'token'); --",
"' AND (SELECT COUNT(*) FROM oauth2_clients) > 0 --",
"'; UPDATE oauth2_clients SET secret='hacked' WHERE id=1; --",
// Encoded SQL injection attempts
'%27%20OR%20%271%27%3D%271',
'%27%3B%20DROP%20TABLE%20users%3B%20--',
// Function-based SQL injection
"'; SELECT LOAD_FILE('/etc/passwd'); --",
"' OR SUBSTRING(password,1,1)='a' --",
// Time-based blind SQL injection
"' OR SLEEP(5) --",
"'; WAITFOR DELAY '00:00:05' --",
];
foreach ($sqlInjectionPayloads as $payload) {
$client->request('POST', '/oauth/v2/token', [
'grant_type' => 'authorization_code',
'client_id' => $apiClient->getPublicId(),
'client_secret' => $apiClient->getSecret(),
'code' => $payload,
'redirect_uri' => 'http://example.com/callback',
]);
$response = $client->getResponse();
// Should return proper error response, not crash or expose data
$this->assertSame(400, $response->getStatusCode(),
'SQL injection payload should return 400 error: ' . $payload);
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('error', $data);
$this->assertSame('invalid_grant', $data['error']);
// Ensure no sensitive data is exposed in error message
$this->assertArrayHasKey('error_description', $data);
$errorDescription = strtolower($data['error_description']);
$this->assertStringNotContainsString('password', $errorDescription);
$this->assertStringNotContainsString('secret', $errorDescription);
$this->assertStringNotContainsString('token', $errorDescription);
}
}
/**
* Test SQL injection attempts in client_id parameter.
*/
public function testSqlInjectionInClientId(): void
{
$client = $this->getTestClient();
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
$em = $client->getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneByUsername('admin');
$authCode = $this->createAuthCode($apiClient, $user);
$sqlInjectionPayloads = [
// Basic SQL injection attempts
"'; DROP TABLE oauth2_clients; --",
"' OR '1'='1",
"' UNION SELECT secret FROM oauth2_clients --",
"'; DELETE FROM users WHERE username='admin'; --",
// Advanced attempts targeting client validation
"' OR EXISTS(SELECT 1 FROM oauth2_clients WHERE secret='secret') --",
"'; INSERT INTO oauth2_clients VALUES (999, 'evil', 'client'); --",
"' AND (SELECT COUNT(*) FROM oauth2_access_tokens) > 0 --",
// Encoded attempts
'%27%20OR%20%271%27%3D%271',
'%27%3B%20DROP%20TABLE%20oauth2_clients%3B%20--',
];
foreach ($sqlInjectionPayloads as $payload) {
$client->request('POST', '/oauth/v2/token', [
'grant_type' => 'authorization_code',
'client_id' => $payload,
'client_secret' => $apiClient->getSecret(),
'code' => $authCode->getToken(),
'redirect_uri' => 'http://example.com/callback',
]);
$response = $client->getResponse();
// Should return proper error response, not crash or expose data
$this->assertSame(400, $response->getStatusCode(),
'SQL injection in client_id should return 400 error: ' . $payload);
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('error', $data);
// Could be invalid_client or invalid_grant depending on validation order
$this->assertContains($data['error'], ['invalid_client', 'invalid_grant']);
}
}
/**
* Test SQL injection attempts in client_secret parameter.
*/
public function testSqlInjectionInClientSecret(): void
{
$client = $this->getTestClient();
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
$em = $client->getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneByUsername('admin');
$authCode = $this->createAuthCode($apiClient, $user);
$sqlInjectionPayloads = [
// Basic SQL injection attempts
"'; DROP TABLE oauth2_clients; --",
"' OR '1'='1",
"' UNION SELECT id FROM oauth2_clients --",
"'; UPDATE oauth2_clients SET secret='hacked'; --",
// Advanced attempts targeting secret validation
"' OR LENGTH(secret) > 0 --",
"'; INSERT INTO oauth2_access_tokens VALUES (1, 'token', 'evil'); --",
"' AND (SELECT secret FROM oauth2_clients WHERE id=" . $apiClient->getId() . ') --',
// Encoded attempts
'%27%20OR%20%271%27%3D%271',
'%27%3B%20DROP%20TABLE%20users%3B%20--',
];
foreach ($sqlInjectionPayloads as $payload) {
$client->request('POST', '/oauth/v2/token', [
'grant_type' => 'authorization_code',
'client_id' => $apiClient->getPublicId(),
'client_secret' => $payload,
'code' => $authCode->getToken(),
'redirect_uri' => 'http://example.com/callback',
]);
$response = $client->getResponse();
// Should return proper error response, not crash or expose data
$this->assertSame(400, $response->getStatusCode(),
'SQL injection in client_secret should return 400 error: ' . $payload);
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('error', $data);
$this->assertSame('invalid_client', $data['error']);
}
}
/**
* Test SQL injection attempts in redirect_uri parameter.
*/
public function testSqlInjectionInRedirectUri(): void
{
$client = $this->getTestClient();
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
$em = $client->getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneByUsername('admin');
$authCode = $this->createAuthCode($apiClient, $user);
$sqlInjectionPayloads = [
// Basic SQL injection attempts
"http://example.com/callback'; DROP TABLE oauth2_auth_codes; --",
"http://example.com/callback' OR '1'='1",
"http://example.com/callback' UNION SELECT token FROM oauth2_auth_codes --",
"http://example.com/callback'; DELETE FROM oauth2_clients; --",
// Advanced attempts targeting redirect URI validation
"http://example.com/callback' OR redirect_uri LIKE '%callback%' --",
"http://example.com/callback'; INSERT INTO oauth2_access_tokens VALUES (1, 'evil'); --",
"http://example.com/callback' AND (SELECT COUNT(*) FROM oauth2_auth_codes) > 0 --",
// Encoded attempts
'http://example.com/callback%27%20OR%20%271%27%3D%271',
'http://example.com/callback%27%3B%20DROP%20TABLE%20users%3B%20--',
];
foreach ($sqlInjectionPayloads as $payload) {
$client->request('POST', '/oauth/v2/token', [
'grant_type' => 'authorization_code',
'client_id' => $apiClient->getPublicId(),
'client_secret' => $apiClient->getSecret(),
'code' => $authCode->getToken(),
'redirect_uri' => $payload,
]);
$response = $client->getResponse();
// Should return proper error response, not crash or expose data
$this->assertSame(400, $response->getStatusCode(),
'SQL injection in redirect_uri should return 400 error: ' . $payload);
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('error', $data);
$this->assertSame('redirect_uri_mismatch', $data['error']);
}
}
/**
* Test SQL injection attempts in PKCE parameters.
*/
public function testSqlInjectionInPkceParameters(): void
{
$client = $this->getTestClient();
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
$apiClient->setIsPublic(true); // Force PKCE requirement
$em = $client->getContainer()->get(EntityManagerInterface::class);
$em->persist($apiClient);
$em->flush();
$user = $em->getRepository(User::class)->findOneByUsername('admin');
// Create auth code with PKCE
$authCode = new AuthCode();
$authCode->setClient($apiClient);
$authCode->setUser($user);
$authCode->setToken($this->generateToken());
$authCode->setRedirectUri('http://example.com/callback');
$authCode->setExpiresAt(time() + 600);
$authCode->setScope('read');
$authCode->setCodeChallenge('test_challenge');
$authCode->setCodeChallengeMethod('S256');
$em->persist($authCode);
$em->flush();
$sqlInjectionPayloads = [
// Basic SQL injection attempts
"'; DROP TABLE oauth2_auth_codes; --",
"' OR '1'='1",
"' UNION SELECT code_challenge FROM oauth2_auth_codes --",
"'; DELETE FROM oauth2_auth_codes WHERE client_id=" . $apiClient->getId() . '; --',
// Advanced attempts targeting PKCE validation
"' OR LENGTH(code_challenge) > 0 --",
"'; INSERT INTO oauth2_access_tokens VALUES (1, 'pkce_token'); --",
"' AND code_challenge_method='S256' --",
// Encoded attempts
'%27%20OR%20%271%27%3D%271',
'%27%3B%20DROP%20TABLE%20oauth2_auth_codes%3B%20--',
];
foreach ($sqlInjectionPayloads as $payload) {
$client->request('POST', '/oauth/v2/token', [
'grant_type' => 'authorization_code',
'client_id' => $apiClient->getPublicId(),
'client_secret' => $apiClient->getSecret(),
'code' => $authCode->getToken(),
'redirect_uri' => 'http://example.com/callback',
'code_verifier' => $payload,
]);
$response = $client->getResponse();
// Should return proper error response, not crash or expose data
$this->assertSame(400, $response->getStatusCode(),
'SQL injection in code_verifier should return 400 error: ' . $payload);
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('error', $data);
$this->assertSame('invalid_grant', $data['error']);
// Ensure no sensitive data is exposed in error message
$this->assertArrayHasKey('error_description', $data);
$errorDescription = strtolower($data['error_description']);
$this->assertStringNotContainsString('challenge', $errorDescription);
$this->assertStringNotContainsString('secret', $errorDescription);
}
}
/**
* Test SQL injection attempts in grant_type parameter.
*/
public function testSqlInjectionInGrantType(): void
{
$client = $this->getTestClient();
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
$em = $client->getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneByUsername('admin');
$authCode = $this->createAuthCode($apiClient, $user);
$sqlInjectionPayloads = [
// Basic SQL injection attempts
"authorization_code'; DROP TABLE oauth2_clients; --",
"authorization_code' OR '1'='1",
"authorization_code' UNION SELECT * FROM users --",
"authorization_code'; DELETE FROM oauth2_access_tokens; --",
// Advanced attempts
"authorization_code' OR grant_type='authorization_code' --",
"authorization_code'; INSERT INTO oauth2_access_tokens VALUES (1, 'evil'); --",
// Encoded attempts
'authorization_code%27%20OR%20%271%27%3D%271',
'authorization_code%27%3B%20DROP%20TABLE%20users%3B%20--',
];
foreach ($sqlInjectionPayloads as $payload) {
$client->request('POST', '/oauth/v2/token', [
'grant_type' => $payload,
'client_id' => $apiClient->getPublicId(),
'client_secret' => $apiClient->getSecret(),
'code' => $authCode->getToken(),
'redirect_uri' => 'http://example.com/callback',
]);
$response = $client->getResponse();
// Should return proper error response, not crash or expose data
$this->assertSame(400, $response->getStatusCode(),
'SQL injection in grant_type should return 400 error: ' . $payload);
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('error', $data);
$this->assertSame('invalid_request', $data['error']);
}
}
/**
* Test that database queries are using prepared statements by ensuring
* SQL injection attempts don't affect the database state.
*/
public function testDatabaseStateIntegrityAfterSqlInjection(): void
{
$client = $this->getTestClient();
$apiClient = $this->createApiClientForUser('admin', ['authorization_code']);
$em = $client->getContainer()->get(EntityManagerInterface::class);
// Record initial database state
$initialClientCount = $em->getRepository(Client::class)->count([]);
$initialAuthCodeCount = $em->getRepository(AuthCode::class)->count([]);
$initialUserCount = $em->getRepository(User::class)->count([]);
// Attempt various destructive SQL injection attacks
$destructivePayloads = [
"'; DROP TABLE oauth2_clients; --",
"'; DELETE FROM oauth2_auth_codes; --",
"'; UPDATE oauth2_clients SET secret='hacked'; --",
"'; INSERT INTO oauth2_access_tokens VALUES (999, 'evil', 'token'); --",
"'; TRUNCATE TABLE users; --",
];
foreach ($destructivePayloads as $payload) {
$client->request('POST', '/oauth/v2/token', [
'grant_type' => 'authorization_code',
'client_id' => $payload,
'client_secret' => $apiClient->getSecret(),
'code' => $payload,
'redirect_uri' => 'http://example.com/callback',
]);
// Clear entity manager to ensure fresh data from database
$em->clear();
}
// Verify database state is unchanged
$finalClientCount = $em->getRepository(Client::class)->count([]);
$finalAuthCodeCount = $em->getRepository(AuthCode::class)->count([]);
$finalUserCount = $em->getRepository(User::class)->count([]);
$this->assertSame($initialClientCount, $finalClientCount,
'SQL injection should not affect oauth2_clients table');
$this->assertSame($initialAuthCodeCount, $finalAuthCodeCount,
'SQL injection should not affect oauth2_auth_codes table');
$this->assertSame($initialUserCount, $finalUserCount,
'SQL injection should not affect users table');
// Verify the test client still exists and is functional
$testClient = $em->getRepository(Client::class)->find($apiClient->getId());
$this->assertNotNull($testClient, 'Test client should still exist after SQL injection attempts');
$this->assertSame($apiClient->getSecret(), $testClient->getSecret(),
'Client secret should be unchanged after SQL injection attempts');
}
/**
* Helper method to create an OAuth client for testing.
* This follows the same pattern as OAuthSecurityTest for consistency.
*/
private function createApiClientForUser($username, $grantTypes = ['password'])
{
$client = $this->getTestClient();
$em = $client->getContainer()->get(EntityManagerInterface::class);
$userManager = static::getContainer()->get('fos_user.user_manager');
$user = $userManager->findUserBy(['username' => $username]);
\assert($user instanceof User);
$apiClient = new Client($user);
$apiClient->setName('Test OAuth Client');
$apiClient->setAllowedGrantTypes($grantTypes);
$apiClient->setRedirectUris(['http://example.com/callback']);
$em->persist($apiClient);
$em->flush();
return $apiClient;
}
/**
* Helper method to create an authorization code.
*/
private function createAuthCode(Client $client, User $user): AuthCode
{
$em = $this->getTestClient()->getContainer()->get(EntityManagerInterface::class);
$authCode = new AuthCode();
$authCode->setClient($client);
$authCode->setUser($user);
$authCode->setToken($this->generateToken());
$authCode->setRedirectUri('http://example.com/callback');
$authCode->setExpiresAt(time() + 600); // 10 minutes
$authCode->setScope('read write');
$em->persist($authCode);
$em->flush();
return $authCode;
}
/**
* Helper method to generate a secure token.
*/
private function generateToken(): string
{
return bin2hex(random_bytes(32));
}
}