config_preview_deploy-1.0.0-alpha3/tests/src/Kernel/SecurityEdgeCaseTest.php
tests/src/Kernel/SecurityEdgeCaseTest.php
<?php
declare(strict_types=1);
namespace Drupal\Tests\config_preview_deploy\Kernel;
use Drupal\config_preview_deploy\Controller\ProductionController;
use Drupal\consumers\Entity\Consumer;
use Drupal\Core\Url;
use Drupal\KernelTests\KernelTestBase;
use Drupal\simple_oauth\Entity\Oauth2Token;
use Drupal\user\Entity\User;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Tests security edge cases and attack vectors.
*
* @group config_preview_deploy
*/
class SecurityEdgeCaseTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'config_preview_deploy',
'simple_oauth',
'simple_oauth_static_scope',
'consumers',
'key',
'system',
'user',
'serialization',
'options',
'file',
'image',
];
/**
* Test user.
*/
protected User $testUser;
/**
* OAuth consumer.
*/
protected Consumer $consumer;
/**
* Production controller.
*/
protected ProductionController $productionController;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Set HTTP_HOST for environment detection.
$_SERVER['HTTP_HOST'] = 'test.example.com';
$this->installEntitySchema('user');
$this->installEntitySchema('consumer');
$this->installEntitySchema('oauth2_token');
$this->installEntitySchema('oauth2_scope');
$this->installEntitySchema('oauth2_token_type');
$this->installEntitySchema('key');
$this->installConfig([
'system',
'user',
'simple_oauth',
'consumers',
'config_preview_deploy',
]);
// Create temporary directory for OAuth keys.
$keysDir = sys_get_temp_dir() . '/oauth_keys_' . uniqid();
mkdir($keysDir, 0777, TRUE);
// Generate test keys.
$this->generateTestKeys($keysDir);
// Configure simple_oauth to use our test keys.
$this->config('simple_oauth.settings')
->set('public_key', $keysDir . '/public.key')
->set('private_key', $keysDir . '/private.key')
->save();
// Create test user with deployment permissions.
$this->testUser = User::create([
'name' => 'deploy_user',
'mail' => 'deploy@example.com',
'status' => 1,
]);
$this->testUser->save();
// Grant the user the required permission.
user_role_grant_permissions('authenticated', ['accept config deployments']);
// Run module install to create OAuth consumer.
\Drupal::moduleHandler()->loadInclude('config_preview_deploy', 'install');
config_preview_deploy_install();
// Load the created consumer.
$consumers = \Drupal::entityTypeManager()
->getStorage('consumer')
->loadByProperties(['client_id' => 'config_preview_deploy']);
$this->consumer = reset($consumers);
// Create controller instance.
$this->productionController = ProductionController::create($this->container);
}
/**
* Helper to issue a request and return the response.
*/
protected function request(string $path, string $method = 'GET', array $query = [], array $headers = [], string $content = ''): Response {
$request = Request::create($path, $method, $query, [], [], [], $content);
// Add headers.
foreach ($headers as $name => $value) {
$request->headers->set($name, $value);
}
return $this->container->get('http_kernel')->handle($request);
}
/**
* Generate test RSA key pair for OAuth.
*/
protected function generateTestKeys(string $keysDir): void {
// Generate private key.
$private_key = openssl_pkey_new([
'digest_alg' => 'sha256',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
// Export private key.
openssl_pkey_export_to_file($private_key, $keysDir . '/private.key');
// Export public key.
$public_key_details = openssl_pkey_get_details($private_key);
file_put_contents($keysDir . '/public.key', $public_key_details['key']);
}
/**
* Tests protection against token replay attacks.
*/
public function testTokenReplayAttackProtection(): void {
// Create a valid token with valid timestamp.
$timestamp = time();
$hashVerification = $this->container->get('config_preview_deploy.hash_verification');
// Get the host from Drupal's base URL to match verification logic.
$currentUrl = Url::fromRoute('system.admin', [], ['absolute' => TRUE])->toString();
$productionHost = parse_url($currentUrl, PHP_URL_HOST);
$token = $hashVerification->generateVerificationHash($productionHost, $timestamp);
$request = Request::create('/api/config-preview-deploy/status', 'GET', [
'timestamp' => $timestamp,
'token' => $token,
]);
// First request should succeed.
$response = $this->productionController->getStatus($request);
$this->assertEquals(200, $response->getStatusCode());
// Try to replay the same token with an old timestamp.
// More than 5 minutes old.
$old_timestamp = $timestamp - 400;
$old_token = $hashVerification->generateVerificationHash($productionHost, $old_timestamp);
$replay_request = Request::create('/api/config-preview-deploy/status', 'GET', [
'timestamp' => $old_timestamp,
'token' => $old_token,
]);
// Replay attack should be blocked.
$response = $this->productionController->getStatus($replay_request);
$this->assertEquals(403, $response->getStatusCode());
$data = json_decode($response->getContent(), TRUE);
$this->assertEquals('Invalid verification hash', $data['error']);
}
/**
* Tests OAuth token scope restriction.
*/
public function testOauthTokenScopeRestriction(): void {
// Create OAuth token with wrong scope.
$wrong_scope_token = Oauth2Token::create([
'bundle' => 'access_token',
'auth_user_id' => $this->testUser->id(),
'client' => $this->consumer->id(),
'scopes' => [['value' => 'wrong_scope']],
'value' => 'wrong-scope-token-' . bin2hex(random_bytes(16)),
'expire' => time() + 3600,
'resource_owner' => $this->testUser->id(),
]);
$wrong_scope_token->save();
// Try to use token with wrong scope via HTTP kernel.
$data = json_encode([
'diff' => 'test diff content',
'environment' => 'test',
'auth_hash' => 'test_hash',
'timestamp' => time(),
]);
$response = $this->request('/admin/config/development/config-preview-deploy/deploy-endpoint', 'POST', [], [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $wrong_scope_token->get('value')->value,
], $data);
// Should be rejected due to wrong scope (OAuth validation at route level).
// 401 = invalid token, 403 = valid token but insufficient scope.
$this->assertTrue(in_array($response->getStatusCode(), [401, 403]),
'OAuth should reject wrong scope token with 401 or 403, got: ' . $response->getStatusCode() . ' - ' . $response->getContent());
}
/**
* Tests expired OAuth token handling.
*/
public function testExpiredOauthTokenHandling(): void {
// Create expired OAuth token.
$token = Oauth2Token::create([
'bundle' => 'access_token',
'auth_user_id' => $this->testUser->id(),
'client' => $this->consumer->id(),
'scopes' => [['value' => 'config_preview_deploy']],
'value' => 'expired-token-' . bin2hex(random_bytes(16)),
// Expired 1 hour ago.
'expire' => time() - 3600,
'resource_owner' => $this->testUser->id(),
]);
$token->save();
// Try to use expired token via HTTP kernel.
$data = json_encode([
'diff' => 'test diff content',
'environment' => 'test',
'auth_hash' => 'test_hash',
'timestamp' => time(),
]);
$response = $this->request('/admin/config/development/config-preview-deploy/deploy-endpoint', 'POST', [], [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $token->get('value')->value,
], $data);
// Should be rejected due to expiration (OAuth validation at route level).
// 401 = invalid token, 403 = valid token but expired.
$this->assertTrue(in_array($response->getStatusCode(), [401, 403]),
'OAuth should reject expired token with 401 or 403, got: ' . $response->getStatusCode());
}
/**
* Tests environment variable security for deployment secrets.
*/
public function testEnvironmentVariableSecurity(): void {
// Test that deployment secret key configuration is secure.
$key_storage = \Drupal::entityTypeManager()->getStorage('key');
$deployment_key = $key_storage->load('config_deploy_secret');
$this->assertNotNull($deployment_key, 'Deployment key should exist');
// Verify key configuration.
$key_provider = $deployment_key->get('key_provider');
$key_provider_value = is_object($key_provider) ? $key_provider->value : $key_provider;
$this->assertTrue(in_array($key_provider_value, ['config', 'env']), 'Key provider should be config or env');
// Test secret value is not empty.
$secret = $deployment_key->getKeyValue();
$this->assertNotEmpty($secret, 'Secret should not be empty');
$this->assertIsString($secret, 'Secret should be a string');
// Test secret has sufficient entropy.
$this->assertGreaterThan(16, strlen($secret), 'Secret should be at least 16 characters');
}
}
