- DB 연결: 로컬/Docker 환경 오버라이딩 설정 (.env) - 테넌트 위젯: redirect 버그 수정 (TenantSelectorWidget) - 통계 위젯: 사용자/제품/자재/주문 카드 추가 (StatsOverviewWidget) - 리소스 한국어화: Product, Material 모델 레이블 추가 - 대시보드: 위젯 등록 및 캐시 최적화 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
414 lines
14 KiB
PHP
414 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Security;
|
|
|
|
use App\Models\Model;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Tests\TestCase;
|
|
|
|
class ApiSecurityTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $user;
|
|
private User $otherTenantUser;
|
|
private Model $model;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// Create users in different tenants
|
|
$this->user = User::factory()->create(['tenant_id' => 1]);
|
|
$this->otherTenantUser = User::factory()->create(['tenant_id' => 2]);
|
|
|
|
// Create test model
|
|
$this->model = Model::factory()->create(['tenant_id' => 1]);
|
|
|
|
// Set required headers
|
|
$this->withHeaders([
|
|
'X-API-KEY' => config('app.api_key', 'test-api-key'),
|
|
'Accept' => 'application/json',
|
|
'Content-Type' => 'application/json',
|
|
]);
|
|
}
|
|
|
|
/** @test */
|
|
public function it_requires_api_key_for_all_endpoints()
|
|
{
|
|
// Remove API key header
|
|
$this->withHeaders([
|
|
'Accept' => 'application/json',
|
|
'Content-Type' => 'application/json',
|
|
]);
|
|
|
|
// Test various endpoints
|
|
$endpoints = [
|
|
['GET', '/api/v1/design/models'],
|
|
['GET', "/api/v1/design/models/{$this->model->id}/parameters"],
|
|
['POST', "/api/v1/design/models/{$this->model->id}/bom/resolve"],
|
|
];
|
|
|
|
foreach ($endpoints as [$method, $endpoint]) {
|
|
$response = $this->json($method, $endpoint);
|
|
$response->assertUnauthorized();
|
|
}
|
|
}
|
|
|
|
/** @test */
|
|
public function it_rejects_invalid_api_keys()
|
|
{
|
|
$this->withHeaders([
|
|
'X-API-KEY' => 'invalid-api-key',
|
|
'Accept' => 'application/json',
|
|
]);
|
|
|
|
$response = $this->getJson('/api/v1/design/models');
|
|
$response->assertUnauthorized();
|
|
}
|
|
|
|
/** @test */
|
|
public function it_requires_authentication_for_protected_routes()
|
|
{
|
|
// Test endpoints that require user authentication
|
|
$protectedEndpoints = [
|
|
['GET', "/api/v1/design/models/{$this->model->id}/parameters"],
|
|
['POST', "/api/v1/design/models/{$this->model->id}/parameters"],
|
|
['POST', "/api/v1/design/models/{$this->model->id}/bom/resolve"],
|
|
['POST', "/api/v1/design/models/{$this->model->id}/products"],
|
|
];
|
|
|
|
foreach ($protectedEndpoints as [$method, $endpoint]) {
|
|
$response = $this->json($method, $endpoint);
|
|
$response->assertUnauthorized();
|
|
}
|
|
}
|
|
|
|
/** @test */
|
|
public function it_enforces_tenant_isolation()
|
|
{
|
|
// Authenticate as user from tenant 1
|
|
Sanctum::actingAs($this->user);
|
|
|
|
// Try to access model from different tenant
|
|
$otherTenantModel = Model::factory()->create(['tenant_id' => 2]);
|
|
|
|
$response = $this->getJson("/api/v1/design/models/{$otherTenantModel->id}/parameters");
|
|
$response->assertNotFound();
|
|
}
|
|
|
|
/** @test */
|
|
public function it_prevents_sql_injection_in_parameters()
|
|
{
|
|
Sanctum::actingAs($this->user);
|
|
|
|
// Test SQL injection attempts in various inputs
|
|
$sqlInjectionPayloads = [
|
|
"'; DROP TABLE models; --",
|
|
"' UNION SELECT * FROM users --",
|
|
"1' OR '1'='1",
|
|
"<script>alert('xss')</script>",
|
|
];
|
|
|
|
foreach ($sqlInjectionPayloads as $payload) {
|
|
// Test in BOM resolution parameters
|
|
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/bom/resolve", [
|
|
'parameters' => [
|
|
'W0' => $payload,
|
|
'H0' => 800,
|
|
'screen_type' => 'SCREEN',
|
|
]
|
|
]);
|
|
|
|
// Should either validate and reject, or handle safely
|
|
$this->assertTrue(
|
|
$response->status() === 422 || $response->status() === 400,
|
|
"SQL injection payload was not properly handled: {$payload}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/** @test */
|
|
public function it_sanitizes_formula_expressions()
|
|
{
|
|
Sanctum::actingAs($this->user);
|
|
|
|
// Test dangerous expressions that could execute arbitrary code
|
|
$dangerousExpressions = [
|
|
'system("rm -rf /")',
|
|
'eval("malicious code")',
|
|
'exec("ls -la")',
|
|
'__import__("os").system("pwd")',
|
|
'file_get_contents("/etc/passwd")',
|
|
];
|
|
|
|
foreach ($dangerousExpressions as $expression) {
|
|
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/formulas", [
|
|
'name' => 'test_formula',
|
|
'expression' => $expression,
|
|
'description' => 'Test formula',
|
|
'return_type' => 'NUMBER',
|
|
'sort_order' => 1,
|
|
]);
|
|
|
|
// Should reject dangerous expressions
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['expression']);
|
|
}
|
|
}
|
|
|
|
/** @test */
|
|
public function it_prevents_xss_in_user_inputs()
|
|
{
|
|
Sanctum::actingAs($this->user);
|
|
|
|
$xssPayloads = [
|
|
'<script>alert("xss")</script>',
|
|
'javascript:alert("xss")',
|
|
'<img src="x" onerror="alert(1)">',
|
|
'<svg onload="alert(1)">',
|
|
];
|
|
|
|
foreach ($xssPayloads as $payload) {
|
|
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/parameters", [
|
|
'name' => 'test_param',
|
|
'label' => $payload,
|
|
'type' => 'NUMBER',
|
|
'default_value' => '0',
|
|
'sort_order' => 1,
|
|
]);
|
|
|
|
// Check that XSS payload is not reflected in response
|
|
if ($response->isSuccessful()) {
|
|
$responseData = $response->json();
|
|
$this->assertStringNotContainsString('<script>', json_encode($responseData));
|
|
$this->assertStringNotContainsString('javascript:', json_encode($responseData));
|
|
$this->assertStringNotContainsString('onerror=', json_encode($responseData));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @test */
|
|
public function it_rate_limits_api_requests()
|
|
{
|
|
Sanctum::actingAs($this->user);
|
|
|
|
$endpoint = "/api/v1/design/models/{$this->model->id}/parameters";
|
|
$successfulRequests = 0;
|
|
$rateLimitHit = false;
|
|
|
|
// Make many requests quickly
|
|
for ($i = 0; $i < 100; $i++) {
|
|
$response = $this->getJson($endpoint);
|
|
|
|
if ($response->status() === 429) {
|
|
$rateLimitHit = true;
|
|
break;
|
|
}
|
|
|
|
if ($response->isSuccessful()) {
|
|
$successfulRequests++;
|
|
}
|
|
}
|
|
|
|
// Should hit rate limit before 100 requests
|
|
$this->assertTrue($rateLimitHit || $successfulRequests < 100, 'Rate limiting is not working properly');
|
|
}
|
|
|
|
/** @test */
|
|
public function it_validates_file_uploads_securely()
|
|
{
|
|
Sanctum::actingAs($this->user);
|
|
|
|
// Test malicious file uploads
|
|
$maliciousFiles = [
|
|
// PHP script disguised as image
|
|
[
|
|
'name' => 'image.php.jpg',
|
|
'content' => '<?php system($_GET["cmd"]); ?>',
|
|
'mime' => 'image/jpeg',
|
|
],
|
|
// Executable file
|
|
[
|
|
'name' => 'malware.exe',
|
|
'content' => 'MZ...', // PE header
|
|
'mime' => 'application/octet-stream',
|
|
],
|
|
// Script with dangerous extension
|
|
[
|
|
'name' => 'script.js',
|
|
'content' => 'alert("xss")',
|
|
'mime' => 'application/javascript',
|
|
],
|
|
];
|
|
|
|
foreach ($maliciousFiles as $file) {
|
|
$uploadedFile = \Illuminate\Http\UploadedFile::fake()->createWithContent(
|
|
$file['name'],
|
|
$file['content']
|
|
);
|
|
|
|
$response = $this->postJson('/api/v1/files/upload', [
|
|
'file' => $uploadedFile,
|
|
'type' => 'model_attachment',
|
|
]);
|
|
|
|
// Should reject malicious files
|
|
$this->assertTrue(
|
|
$response->status() === 422 || $response->status() === 400,
|
|
"Malicious file was not rejected: {$file['name']}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/** @test */
|
|
public function it_prevents_mass_assignment_vulnerabilities()
|
|
{
|
|
Sanctum::actingAs($this->user);
|
|
|
|
// Try to mass assign protected fields
|
|
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/parameters", [
|
|
'name' => 'test_param',
|
|
'label' => 'Test Parameter',
|
|
'type' => 'NUMBER',
|
|
'tenant_id' => 999, // Should not be mass assignable
|
|
'created_by' => 999, // Should not be mass assignable
|
|
'id' => 999, // Should not be mass assignable
|
|
]);
|
|
|
|
if ($response->isSuccessful()) {
|
|
$parameter = $response->json('data');
|
|
|
|
// These fields should not be affected by mass assignment
|
|
$this->assertEquals(1, $parameter['tenant_id']); // Should use authenticated user's tenant
|
|
$this->assertEquals($this->user->id, $parameter['created_by']); // Should use authenticated user
|
|
$this->assertNotEquals(999, $parameter['id']); // Should be auto-generated
|
|
}
|
|
}
|
|
|
|
/** @test */
|
|
public function it_handles_concurrent_requests_safely()
|
|
{
|
|
Sanctum::actingAs($this->user);
|
|
|
|
// Simulate concurrent creation of parameters
|
|
$promises = [];
|
|
for ($i = 0; $i < 10; $i++) {
|
|
$promises[] = $this->postJson("/api/v1/design/models/{$this->model->id}/parameters", [
|
|
'name' => "concurrent_param_{$i}",
|
|
'label' => "Concurrent Parameter {$i}",
|
|
'type' => 'NUMBER',
|
|
'sort_order' => $i,
|
|
]);
|
|
}
|
|
|
|
// All requests should be handled without errors
|
|
foreach ($promises as $response) {
|
|
$this->assertTrue($response->isSuccessful() || $response->status() === 422);
|
|
}
|
|
|
|
// Check for race conditions in database
|
|
$parameters = \App\Models\ModelParameter::where('model_id', $this->model->id)->get();
|
|
$sortOrders = $parameters->pluck('sort_order')->toArray();
|
|
|
|
// Should not have duplicate sort orders if handling concurrency properly
|
|
$this->assertEquals(count($sortOrders), count(array_unique($sortOrders)));
|
|
}
|
|
|
|
/** @test */
|
|
public function it_logs_security_events()
|
|
{
|
|
// Test that security events are properly logged
|
|
$this->withHeaders([
|
|
'X-API-KEY' => 'invalid-api-key',
|
|
]);
|
|
|
|
$response = $this->getJson('/api/v1/design/models');
|
|
$response->assertUnauthorized();
|
|
|
|
// Check that failed authentication is logged
|
|
$this->assertDatabaseHas('audit_logs', [
|
|
'action' => 'authentication_failed',
|
|
'ip' => request()->ip(),
|
|
]);
|
|
}
|
|
|
|
/** @test */
|
|
public function it_protects_against_timing_attacks()
|
|
{
|
|
// Test that authentication timing is consistent
|
|
$validKey = config('app.api_key');
|
|
$invalidKey = 'invalid-key-with-same-length-as-valid-key';
|
|
|
|
$validKeyTimes = [];
|
|
$invalidKeyTimes = [];
|
|
|
|
// Measure timing for valid key
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$start = microtime(true);
|
|
$this->withHeaders(['X-API-KEY' => $validKey])
|
|
->getJson('/api/v1/design/models');
|
|
$validKeyTimes[] = microtime(true) - $start;
|
|
}
|
|
|
|
// Measure timing for invalid key
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$start = microtime(true);
|
|
$this->withHeaders(['X-API-KEY' => $invalidKey])
|
|
->getJson('/api/v1/design/models');
|
|
$invalidKeyTimes[] = microtime(true) - $start;
|
|
}
|
|
|
|
$avgValidTime = array_sum($validKeyTimes) / count($validKeyTimes);
|
|
$avgInvalidTime = array_sum($invalidKeyTimes) / count($invalidKeyTimes);
|
|
|
|
// Timing difference should not be significant (within 50ms)
|
|
$timingDifference = abs($avgValidTime - $avgInvalidTime);
|
|
$this->assertLessThan(0.05, $timingDifference, 'Timing attack vulnerability detected');
|
|
}
|
|
|
|
/** @test */
|
|
public function it_validates_formula_complexity()
|
|
{
|
|
Sanctum::actingAs($this->user);
|
|
|
|
// Test extremely complex formulas that could cause DoS
|
|
$complexFormula = str_repeat('(', 1000) . 'W0' . str_repeat(')', 1000);
|
|
|
|
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/formulas", [
|
|
'name' => 'complex_formula',
|
|
'expression' => $complexFormula,
|
|
'description' => 'Overly complex formula',
|
|
'return_type' => 'NUMBER',
|
|
]);
|
|
|
|
// Should reject overly complex formulas
|
|
$response->assertStatus(422);
|
|
}
|
|
|
|
/** @test */
|
|
public function it_prevents_path_traversal_attacks()
|
|
{
|
|
Sanctum::actingAs($this->user);
|
|
|
|
// Test path traversal in file operations
|
|
$pathTraversalPayloads = [
|
|
'../../../etc/passwd',
|
|
'..\\..\\..\\windows\\system32\\config\\sam',
|
|
'%2e%2e%2f%2e%2e%2f%2e%2e%2f',
|
|
'....//....//....//etc/passwd',
|
|
];
|
|
|
|
foreach ($pathTraversalPayloads as $payload) {
|
|
$response = $this->getJson("/api/v1/files/{$payload}");
|
|
|
|
// Should not allow path traversal
|
|
$this->assertTrue(
|
|
$response->status() === 404 || $response->status() === 400,
|
|
"Path traversal not prevented for: {$payload}"
|
|
);
|
|
}
|
|
}
|
|
} |