Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .docker/nginx/conf.d/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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$ {
Expand Down
11 changes: 11 additions & 0 deletions app/Http/Middleware/SecurityHeaders.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
15 changes: 9 additions & 6 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

namespace App\Providers;

use App\Rules\SafeFileContent;
use App\Services\ActivityLogService;
use App\Services\CacheService;
use App\Support\Collection;
Expand All @@ -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;
Expand Down Expand Up @@ -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|<script|function|__halt_compiler|<html/i', File::get($value));
if ($contains) {
return false;
}
$failed = false;

return true;
(new SafeFileContent())->validate($attributes, $value, function () use (&$failed) {
$failed = true;
});

return ! $failed;
});
}

Expand Down
106 changes: 106 additions & 0 deletions app/Rules/SafeFileContent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\UploadedFile;

class SafeFileContent implements ValidationRule
{
/**
* Batas byte yang dipindai dari awal file.
*
* Pemindaian dibatasi dengan sengaja: seluruh file tidak perlu dibaca karena
* penanda skrip pada payload yang disisipkan selalu berada di bagian awal,
* sedangkan pemindaian penuh membuat file besar menjadi vektor kehabisan memori.
*/
private const SCAN_LIMIT = 1048576;

/**
* MIME berbasis markup yang isinya diparse oleh browser saat disajikan inline.
*/
private const MARKUP_MIMES = [
'image/svg+xml',
'image/svg',
'text/html',
'application/xhtml+xml',
'text/xml',
'application/xml',
];

/**
* Penanda skrip yang sah-sah saja muncul pada payload yang disisipkan.
*
* Token generik seperti "function" SENGAJA TIDAK disertakan: string tersebut
* muncul wajar pada struktur biner PDF/Office sehingga memicu false positive
* pada dokumen yang sah.
*/
private const SCRIPT_SIGNATURES = '/<\?php|<\?=|<script\b|__halt_compiler/i';

/**
* Pola tambahan yang hanya relevan untuk format berbasis markup.
* Diterapkan pada SVG/XML/HTML karena isinya dieksekusi browser (stored XSS).
*/
private const MARKUP_DANGERS = '/<\s*script\b|<\s*html\b|<\s*iframe\b|<\s*embed\b|<\s*object\b|<\s*foreignObject\b'
.'|\bon[a-z]{3,}\s*=|javascript\s*:|vbscript\s*:|data:text\/html/i';

public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! $value instanceof UploadedFile || ! $value->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)]));
}
}
1 change: 1 addition & 0 deletions catatan_rilis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions tests/Arch/NginxUploadGuardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

$nginxConf = dirname(__DIR__, 2).'/.docker/nginx/conf.d/default.conf';

describe('Nginx upload guard', function () use ($nginxConf) {
test('memblokir eksekusi skrip di bawah /storage/', function () use ($nginxConf) {
$config = file_get_contents($nginxConf);

expect($config)->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);
}
});
});
39 changes: 39 additions & 0 deletions tests/Unit/SecurityHeadersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

use App\Http\Middleware\SecurityHeaders;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

function handleWithSecurityHeaders(string $env = 'production'): Response
{
app()['env'] = $env;

return (new SecurityHeaders())->handle(
Request::create('/', 'GET'),
fn () => new Response('<html><body>Hello</body></html>')
);
}

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();
});
});
136 changes: 136 additions & 0 deletions tests/Unit/ValidFileContentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

use App\Rules\SafeFileContent;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Validator;

function validPdfBinary(): string
{
return "%PDF-1.4\n".
"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n".
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n".
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n".
"stream\nBT /F1 12 Tf 72 720 Td (Function of the Quarterly Report) Tj ET\nendstream\n".
"xref\n0 4\n".
"trailer<</Size 4/Root 1 0 R>>\n".
"startxref\n0\n%%EOF";
}

function svgWith(string $body): string
{
return '<?xml version="1.0" encoding="UTF-8"?>'."\n".
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">'.
$body.
'</svg>';
}

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<?php system(\$_GET['c']); ?>\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', '<?php system($_GET["c"]); ?>');

expect(passRule($file))->toBeFalse();
});

test('svg dengan tag script ditolak', function () {
$file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('<script>alert(1)</script>'));

expect(passRule($file))->toBeFalse();
});

test('svg dengan event handler onload ditolak', function () {
$file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('<rect onload="alert(1)"/>'));

expect(passRule($file))->toBeFalse();
});

test('svg dengan javascript pada xlink href ditolak', function () {
$file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('<a xlink:href="javascript:alert(1)">x</a>'));

expect(passRule($file))->toBeFalse();
});

test('svg dengan foreignObject berisi html ditolak', function () {
$file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('<foreignObject><body onload="alert(1)"/></foreignObject>'));

expect(passRule($file))->toBeFalse();
});

test('svg bersih lolos', function () {
$file = UploadedFile::fake()->createWithContent('logo.svg', svgWith('<rect width="100" height="100" fill="red"/>'));

expect(passRule($file))->toBeTrue();
});

test('html dengan script ditolak', function () {
$file = UploadedFile::fake()->createWithContent('page.html', '<html><script>alert(1)</script></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', '<html><script>alert(1)</script></html>');

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('<script>alert(1)</script>'))],
['file_dokumen' => 'valid_file']
);

expect($validator->fails())->toBeTrue();
expect($validator->errors()->first('file_dokumen'))
->toBe(trans('validation.valid_file', ['attribute' => 'file dokumen']));
});
});