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|'));
+
+ 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']));
+ });
+});