diff --git a/.docker/nginx/conf.d/default.conf b/.docker/nginx/conf.d/default.conf index a53cff6eff..0dcc120116 100644 --- a/.docker/nginx/conf.d/default.conf +++ b/.docker/nginx/conf.d/default.conf @@ -17,6 +17,13 @@ server { try_files $uri $uri/ /index.php?$query_string; } + # File hasil upload disajikan langsung dari storage/app/public (symlink + # public/storage) sehingga tidak melewati aplikasi. Blokir eksekusi skrip + # di jalur ini sebagai lap keamanan terakhir. + location ~* ^/storage/.*\.(php|php[0-9]?|phtml|phar|pl|py|rb|sh|bash|cgi|jsp|asp|aspx|htaccess)$ { + deny all; + } + error_page 404 /index.php; location ~ \.php$ { diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php index c7441b2932..83ba878258 100644 --- a/app/Http/Middleware/SecurityHeaders.php +++ b/app/Http/Middleware/SecurityHeaders.php @@ -47,6 +47,17 @@ public function handle($request, Closure $next) /** @var \Illuminate\Http\Response $response */ $response = $next($request); + foreach ($this->unwantedHeaders as $header) { + $response->headers->remove($header); + } + + /* + * Cegah browser menebak-nebak Content-Type dari isi file. Tanpa header ini + * file markup seperti SVG dapat dieksekusi sebagai script meskipun + * header dan nama ekstensinya tidak menyerupai HTML. + */ + $response->headers->set('X-Content-Type-Options', 'nosniff'); + if (app()->environment('production')) { $localDomain = env('APP_URL', 'http://localhost'); $urlDatabaseGabungan = config('setting.api_server_database_gabungan'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a9f28e67ed..c77edb3db7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -31,6 +31,7 @@ namespace App\Providers; +use App\Rules\SafeFileContent; use App\Services\ActivityLogService; use App\Services\CacheService; use App\Support\Collection; @@ -44,7 +45,6 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; use Illuminate\Support\ServiceProvider; @@ -244,16 +244,19 @@ protected function blade(): void /** * Validator untuk file upload (mencegah file berbahaya). + * + * Logika berada di App\Rules\SafeFileContent agar dapat diuji terpisah. */ protected function file(): void { Validator::extend('valid_file', function ($attributes, $value, $parameters) { - $contains = preg_match('/<\?php|validate($attributes, $value, function () use (&$failed) { + $failed = true; + }); + + return ! $failed; }); } diff --git a/app/Rules/SafeFileContent.php b/app/Rules/SafeFileContent.php new file mode 100644 index 0000000000..a4b33c840c --- /dev/null +++ b/app/Rules/SafeFileContent.php @@ -0,0 +1,106 @@ +isValid()) { + $this->fail($attribute, $fail); + + return; + } + + $head = $this->readHead($value); + + if ($this->matches(self::SCRIPT_SIGNATURES, $head)) { + $this->fail($attribute, $fail); + + return; + } + + $mime = $value->getMimeType() ?? ''; + + if (in_array($mime, self::MARKUP_MIMES, true) && $this->matches(self::MARKUP_DANGERS, $head)) { + $this->fail($attribute, $fail); + } + } + + /** + * Baca bagian awal file tanpa memuat seluruh isinya ke memori. + */ + protected function readHead(UploadedFile $file): string + { + $path = $file->getRealPath(); + + if ($path === false || ! is_readable($path)) { + return ''; + } + + $handle = @fopen($path, 'rb'); + + if ($handle === false) { + return ''; + } + + try { + $head = fread($handle, self::SCAN_LIMIT); + + return $head === false ? '' : $head; + } finally { + fclose($handle); + } + } + + protected function matches(string $pattern, string $content): bool + { + return $content !== '' && preg_match($pattern, $content) === 1; + } + + protected function fail(string $attribute, Closure $fail): void + { + $fail(__('validation.valid_file', ['attribute' => str_replace('_', ' ', $attribute)])); + } +} diff --git a/catatan_rilis.md b/catatan_rilis.md index 59c8dbff7a..50cfef19ad 100644 --- a/catatan_rilis.md +++ b/catatan_rilis.md @@ -21,6 +21,7 @@ Terimakasih [isi disini] yang telah berkontribusi langsung mengembangkan aplikas 10. [#1735](https://github.com/OpenSID/OpenDK/issues/1735) Periksa bagian tambah ketua lembaga ketika menggunakan API satu data. 11. [#1736](http://github.com/OpenSID/OpenDK/issues/1736) Fix Penyesuaian Validasi NIK pada Pengiriman Keluhan untuk Database Gabungan (API Satu Data). 12. [#1737](https://github.com/OpenSID/OpenDK/issues/1737) fungsi kirim pesan error 500. +13. [#1761](https://github.com/OpenSID/OpenDK/issues/1761)Perbaikan validasi valid_file menolak PDF sah karena string generik "function". #### TEKNIS diff --git a/tests/Arch/NginxUploadGuardTest.php b/tests/Arch/NginxUploadGuardTest.php new file mode 100644 index 0000000000..48c9df24b8 --- /dev/null +++ b/tests/Arch/NginxUploadGuardTest.php @@ -0,0 +1,31 @@ +toContain('^/storage/'); + expect($config)->toMatch('/location\s+~\*\s+\^\/storage\/.*phtml/'); + }); + + test('guard /storage/ ditulis sebelum location .php agar tidak ditimpa', function () use ($nginxConf) { + $config = file_get_contents($nginxConf); + + $storageGuard = strpos($config, '^/storage/'); + $phpHandler = strpos($config, 'location ~ \.php$'); + + expect($storageGuard)->not->toBeFalse(); + expect($phpHandler)->not->toBeFalse(); + expect($storageGuard)->toBeLessThan($phpHandler); + }); + + test('blokir mencakup daftar ekstensi eksekutable yang umum', function () use ($nginxConf) { + $config = file_get_contents($nginxConf); + + foreach (['php', 'phtml', 'phar', 'cgi', 'jsp', 'asp'] as $extension) { + expect($config)->toContain($extension); + } + }); +}); diff --git a/tests/Unit/SecurityHeadersTest.php b/tests/Unit/SecurityHeadersTest.php new file mode 100644 index 0000000000..f89ecd5165 --- /dev/null +++ b/tests/Unit/SecurityHeadersTest.php @@ -0,0 +1,39 @@ +handle( + Request::create('/', 'GET'), + fn () => new Response('Hello') + ); +} + +describe('SecurityHeaders nosniff', function () { + test('menambahkan X-Content-Type-Options nosniff di production', function () { + $response = handleWithSecurityHeaders('production'); + + expect($response->headers->get('X-Content-Type-Options'))->toBe('nosniff'); + }); + + test('nosniff tetap dipasang di luar production', function () { + try { + $response = handleWithSecurityHeaders('testing'); + + expect($response->headers->get('X-Content-Type-Options'))->toBe('nosniff'); + } finally { + app()['env'] = 'testing'; + } + }); + + test('tidak membocorkan versi server', function () { + $response = handleWithSecurityHeaders('production'); + + expect($response->headers->has('X-Powered-By'))->toBeFalse(); + }); +}); diff --git a/tests/Unit/ValidFileContentTest.php b/tests/Unit/ValidFileContentTest.php new file mode 100644 index 0000000000..da644a0416 --- /dev/null +++ b/tests/Unit/ValidFileContentTest.php @@ -0,0 +1,136 @@ +>endobj\n". + "2 0 obj<>endobj\n". + "3 0 obj<>endobj\n". + "stream\nBT /F1 12 Tf 72 720 Td (Function of the Quarterly Report) Tj ET\nendstream\n". + "xref\n0 4\n". + "trailer<>\n". + "startxref\n0\n%%EOF"; +} + +function svgWith(string $body): string +{ + return ''."\n". + ''. + $body. + ''; +} + +function passRule(mixed $file): bool +{ + $failed = false; + (new SafeFileContent())->validate('file', $file, function () use (&$failed) { + $failed = true; + }); + + return ! $failed; +} + +describe('valid_file content inspection', function () { + test('pdf valid yang mengandung string Function tetap lolos', function () { + $file = UploadedFile::fake()->createWithContent('renja.pdf', validPdfBinary()); + + expect(passRule($file))->toBeTrue(); + }); + + test('pdf tanpa konten berbahaya lolos', function () { + $file = UploadedFile::fake()->createWithContent('dokumen.pdf', "%PDF-1.7\n%%EOF"); + + expect(passRule($file))->toBeTrue(); + }); + + test('pdf yang menyamar dengan header pdf tetapi berisi kode php ditolak', function () { + $polyglot = "%PDF-1.4\n\n%%EOF"; + $file = UploadedFile::fake()->createWithContent('shell.pdf', $polyglot); + + expect(passRule($file))->toBeFalse(); + }); + + test('file php yang diberi ekstensi pdf ditolak', function () { + $file = UploadedFile::fake()->createWithContent('shell.pdf', ''); + + expect(passRule($file))->toBeFalse(); + }); + + test('svg dengan tag script ditolak', function () { + $file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('')); + + expect(passRule($file))->toBeFalse(); + }); + + test('svg dengan event handler onload ditolak', function () { + $file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('')); + + expect(passRule($file))->toBeFalse(); + }); + + test('svg dengan javascript pada xlink href ditolak', function () { + $file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('x')); + + expect(passRule($file))->toBeFalse(); + }); + + test('svg dengan foreignObject berisi html ditolak', function () { + $file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('')); + + expect(passRule($file))->toBeFalse(); + }); + + test('svg bersih lolos', function () { + $file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('')); + + expect(passRule($file))->toBeTrue(); + }); + + test('html dengan script ditolak', function () { + $file = UploadedFile::fake()->createWithContent('page.html', ''); + + expect(passRule($file))->toBeFalse(); + }); + + test('file yang bukan uploaded file ditolak', function () { + expect(passRule('bukan-file'))->toBeFalse(); + expect(passRule(null))->toBeFalse(); + }); + + test('gambar png dan jpeg asli tetap lolos', function () { + expect(passRule(UploadedFile::fake()->image('foto.png', 20, 20)))->toBeTrue(); + expect(passRule(UploadedFile::fake()->image('foto.jpg', 20, 20)))->toBeTrue(); + }); + + test('file yang menyamar sebagai gambar tetapi berisi html ditolak', function () { + $file = UploadedFile::fake()->createWithContent('foto.png', ''); + + expect(passRule($file))->toBeFalse(); + }); +}); + +describe('valid_file rule registration', function () { + test('rule valid_file menerima pdf berisi string Function', function () { + $validator = Validator::make( + ['file_dokumen' => UploadedFile::fake()->createWithContent('renja.pdf', validPdfBinary())], + ['file_dokumen' => 'valid_file'] + ); + + expect($validator->passes())->toBeTrue(); + }); + + test('rule valid_file menolak svg berbahaya', function () { + $validator = Validator::make( + ['file_dokumen' => UploadedFile::fake()->createWithContent('logo.svg', svgWith(''))], + ['file_dokumen' => 'valid_file'] + ); + + expect($validator->fails())->toBeTrue(); + expect($validator->errors()->first('file_dokumen')) + ->toBe(trans('validation.valid_file', ['attribute' => 'file dokumen'])); + }); +});