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
82 changes: 82 additions & 0 deletions app/Http/Controllers/ImageProxyController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class ImageProxyController extends Controller
{
private const MAX_CACHE_BYTES = 1048576; // 1MB default, configurable

public function proxy(Request $request): Response
{
$url = $request->get('url');

if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) {
Log::warning('ImageProxyController: Invalid URL provided', ['url' => $url]);
abort(400, 'Invalid URL');
}

$parsed = parse_url($url);
$host = $parsed['host'] ?? null;

if (!$host) {
Log::warning('ImageProxyController: Invalid host', ['url' => $url]);
abort(400, 'Invalid host');
}

$cacheKey = 'image_proxy_' . md5($url);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] 🐛 Bug: SSRF — external URL fetched without host/IP whitelist or SSRF protections

Kode: $response = Http::timeout(10)->get($url);

Skenario: User mengirimkan URL ke /image-proxy. Kode hanya memvalidasi bahwa stringnya adalah sebuah URL (filter_var) lalu langsung melakukan Http::get($url). Tanpa pembatasan host/whitelist atau pemeriksaan alamat IP, attacker bisa mengarahkan request ke internal services (http://127.0.0.1:8080, http://169.254.169.254, internal intranet) atau ke layanan internal lain — SSRF. Ini dapat mengakibatkan kebocoran data internal, pemicu aksi internal, atau akses ke metadata endpoints (cloud metadata).

Dampak: Eksekusi request terhadap host internal, kebocoran kredensial/metadata, remote port scanning, dan potensi compromise. Production dapat menjadi target SSRF besar-besaran melalui endpoint publik ini.

Fix: Terapkan whitelist domain/host atau penolakan terhadap alamat IP privat/loopback sebelum memanggil Http::get. Contoh implementasi (masukkan ke sebelum baris yang mengeksekusi Http::get):

// parse host and enforce allowlist
$host = parse_url($url, PHP_URL_HOST) ?? '';
$allowedHosts = config('image_proxy.allowed_hosts', []); // e.g., ['example.com']
if (! in_array($host, $allowedHosts, true)) {
    Log::warning('ImageProxy: host not allowed', ['url' => $url, 'host' => $host]);
    abort(400, 'Host not allowed');
}

// additionally block private IPs (resolve and check)
$ips = gethostbynamel($host) ?: [];
foreach ($ips as $ip) {
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
        Log::warning('ImageProxy: resolved to private/reserved IP', ['url' => $url, 'ip' => $ip]);
        abort(400, 'Host resolves to private or reserved IP');
    }
}

$response = Http::timeout(10)->get($url);

Catatan:

  • Tambahkan konfigurasi allowlist di config/image_proxy.php dengan key allowed_hosts.
  • Atau gunakan denylist untuk private/reserved IPs + allowlist, tergantung kebijakan.
  • Ini mengurangi SSRF attack surface secara signifikan.

$cachedImage = Cache::get($cacheKey);
if ($cachedImage) {
Log::info('ImageProxyController: Serving cached image', ['url' => $url]);
return response($cachedImage['content'])->header('Content-Type', $cachedImage['content_type']);
}

$timeout = config('image_proxy.default_timeout', 10);
try {
$response = Http::timeout($timeout)->get($url);
} catch (\Exception $e) {
Log::warning('ImageProxyController: External image request failed', [
'url' => $url,
'error' => $e->getMessage()
]);
abort(404, 'Image not found');
}

if (!$response->successful()) {
Log::warning('ImageProxyController: External image request failed', [
'url' => $url,
'status' => $response->status()
]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] 🐛 Bug: Caching full binary response into cache store without size checks

Kode: Cache::put($cacheKey, ['content' => $content, 'content_type' => $contentType], 3600);

Skenario: Controller menyimpan isi respon (bisa berupa gambar besar) langsung ke cache store (file/redis/memcached). Jika attacker atau user mengarahkan proxy ke resource besar (multi-MB atau tens/hundreds MB), cache dapat cepat menghabiskan storage/memory, menyebabkan OOM atau degradasi performa. Selain itu, beberapa cache backends tidak cocok menyimpan binary besar.

Dampak: Exhaustion sumber daya (RAM/disk), service degradation, potensi crash atau slowdowns di production.

Fix: Batasi ukuran yang dicache; jangan cache payload yang melebihi ambang; gunakan storage yang sesuai untuk binary (filesystem / object store) atau skip caching. Contoh implementasi:

$maxCacheBytes = config('image_proxy.max_cache_bytes', 1024 * 1024); // default 1MB
$bytes = strlen($content);

if ($bytes <= $maxCacheBytes) {
    Cache::put($cacheKey, ['content' => $content, 'content_type' => $contentType], 3600);
    Log::info('ImageProxy: cached image', ['url' => $url, 'key' => $cacheKey, 'bytes' => $bytes]);
} else {
    Log::warning('ImageProxy: skipping cache, payload too large', ['url' => $url, 'bytes' => $bytes, 'max' => $maxCacheBytes]);
}

Tambahan rekomendasi:

  • Pertimbangkan menyimpan binary besar di filesystem atau object storage (S3) dan cache hanya pointer/metadata.
  • Tambahkan antrian background untuk memproses/cacheing jika perlu, bukan synchronous Cache::put pada request jalan utama.

abort(404, 'Image not found');
}

$content = $response->body();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] ⚡ Performance: Loading full response body into memory and caching binary blobs
Masalah: Controller memanggil dan menyimpan seluruh body response ke memori sebelum mengirim dan menyimpan ke cache. Kode-kode berurutan terkait ini adalah titik masalah: heavy download + in-memory caching untuk setiap request. Saat banyak permintaan atau file besar, ini dapat menyebabkan penggunaan memori tinggi dan tekanan pada cache backend (terutama Redis/memcached), mengakibatkan OOM, evictions, atau degradasi performa.
Kode: $content = $response->body();
Dampak: Untuk image besar (mis. beberapa MB) setiap permintaan non-cached akan mengalokasikan memori sebesar ukuran image. Pada traffic tinggi (ribuan permintaan/menit) atau images besar, server dapat kehabisan memori atau cache penuh. Estimasi: +several MB per concurrent request; 1000 concurrent x 2MB = ~2GB tambahan memory usage. Pada Redis cache, banyak item binary besar memakan memory dan menyebabkan evictions serta latency.
Fix: Hindari memuat seluruh body ke memori: gunakan streaming / temp file / filesystem cache, dan batasi ukuran yang di-cache. Contoh perbaikan (skeleton):

// gunakan stream untuk menghindari body() penuh
$response = Http::timeout(10)->withOptions(['stream' => true])->get($url);
if (!$response->successful()) { abort(404); }
// baca stream ke temp file atau langsung stream ke client
$stream = $response->getBody(); // PSR-7 stream
// jika ingin cache, simpan ke disk (storage) jika ukurannya < MAX_BYTES

Atau minimal batasi dan skip caching untuk response > MAX_SIZE:

$maxBytes = 1024 * 1024 * 2; // 2MB
$length = (int) $response->header('Content-Length', 0);
if ($length > $maxBytes) {
    // don't cache, stream directly
    return response()->stream(function() use ($stream) {
        while (!$stream->eof()) {
            echo $stream->read(1024 * 8);
        }
    }, 200, ['Content-Type' => $contentType]);
}
// else read body and cache safely (or store to disk)

$contentType = $response->header('Content-Type') ?? '';

if (!str_starts_with($contentType, 'image/')) {
Log::warning('ImageProxyController: URL does not point to an image', [
'url' => $url,
'content_type' => $contentType
]);
abort(400, 'URL does not point to an image');
}

$maxCacheBytes = config('image_proxy.max_cache_bytes', self::MAX_CACHE_BYTES);
$bytes = strlen($content);

if ($bytes <= $maxCacheBytes) {
Cache::put($cacheKey, ['content' => $content, 'content_type' => $contentType], config('image_proxy.cache_ttl', 3600));
Log::info('ImageProxyController: Image cached and served', ['url' => $url, 'content_type' => $contentType, 'bytes' => $bytes]);
} else {
Log::warning('ImageProxyController: Skipping cache, payload too large', ['url' => $url, 'bytes' => $bytes, 'max' => $maxCacheBytes]);
}

return response($content)->header('Content-Type', $contentType);
}
}
2 changes: 1 addition & 1 deletion app/Policies/CustomCSPPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public function configure()
$this->addDirective(Directive::IMG, ['blob:'])
->addDirective(Directive::STYLE, ['unsafe-inline']);
}
$this->addDirective(Directive::IMG, ['data:', 'https://tile.openstreetmap.org/'])
$this->addDirective(Directive::IMG, [Keyword::SELF, 'data:', 'https://tile.openstreetmap.org/'])
->addDirective(Directive::STYLE, [
// 'unsafe-inline',
'https://fonts.googleapis.com/',
Expand Down
3 changes: 2 additions & 1 deletion catatan_rilis.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Di rilis ini, versi 2605.0.1 berisi penambahan dan perbaikan yang diminta pengguna.
Di rilis ini, versi 2606.0.0 berisi penambahan dan perbaikan yang diminta pengguna.

#### Penambahan Fitur

Expand All @@ -21,6 +21,7 @@ Di rilis ini, versi 2605.0.1 berisi penambahan dan perbaikan yang diminta penggu
1. [#1023](https://github.com/OpenSID/OpenKab/issues/1023) Percobaan login gagal terkadang error 500
2. [#1026](https://github.com/OpenSID/OpenKab/issues/1026) Perbaikan fungsi insert media dan gambar pada tinymce artikel
3. [#1032](https://github.com/OpenSID/OpenKab/issues/1032) Perbaikan Tombol enter refresh halaman di kategori artikel opensid
4. [#1037](https://github.com/OpenSID/OpenKab/issues/1037) Perbaikan Gambar desa aktif pada halaman website openkab masih statis

#### Perubahan Teknis

Expand Down
12 changes: 12 additions & 0 deletions config/image_proxy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

return [

'max_cache_bytes' => 1024 * 1024 * 5, // 5MB

'default_timeout' => 10,

'cache_ttl' => 3600,

'enabled' => env('IMAGE_PROXY_ENABLED', true),
];
18 changes: 13 additions & 5 deletions resources/views/web/partials/property.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.1s">
<div class="kelurahan-item rounded overflow-hidden">
<div class="position-relative overflow-hidden">
<a href=""><img class="img-fluid" src="{{ asset('web/img/keluarahan-1.jpg') }}"
<a href=""><img class="img-fluid logo-desa-elm" src="{{ asset('web/img/keluarahan-1.jpg') }}"
alt=""></a>
</div>
<div class="p-4 pb-0">
Expand Down Expand Up @@ -57,6 +57,9 @@ class="fa fa-home text-primary me-2"></i>0
result.data.forEach((item, index) => {
_elm = $('.replace-content-property .kelurahan-item').eq(index)
_elm.find('.nama-desa-elm').text(item.attributes.nama_desa)
if(item.attributes.logo){
_elm.find('.logo-desa-elm').attr('src','/image-proxy?url=' + encodeURIComponent(item.attributes.logo))
}
_elm.find('.website-elm').attr('href', item.attributes.website ?? '#')
_elm.find('.penduduk-elm').html(
'<i class="fa fa-users text-primary me-2"></i>' + item.attributes
Expand All @@ -67,10 +70,15 @@ class="fa fa-home text-primary me-2"></i>0
_elm.find('.keluarga-elm').html(
'<i class="fa fa-venus-mars text-primary me-2"></i>' + item
.attributes.keluarga + ' Keluarga')
_elm.find('.rtm-elm').html('<i class="fa fa-home text-primary me-2"></i>' +
item.attributes.rtm + ' RTM')
})
}
_elm.find('.rtm-elm').html('<i class="fa fa-home text-primary me-2"></i>' +
item.attributes.rtm + ' RTM')
})

// Error handler untuk gambar logo yang gagal load
$('.logo-desa-elm').off('error').on('error', function() {
$(this).attr('src', '{{ asset('web/img/keluarahan-1.jpg') }}');
});
}
}, 'json')
});
</script>
Expand Down
4 changes: 4 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use App\Http\Controllers\AdminWebController;
use App\Http\Controllers\ImageProxyController;
use App\Http\Controllers\Web\ArtikelController;
use App\Http\Controllers\ApiProxyController;
use App\Http\Controllers\Auth\ChangePasswordController;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] ⚡ Performance/Security: Public image-proxy route without rate limiting
Masalah: Route baru terdaftar publik: Route::get('/image-proxy', [ImageProxyController::class, 'proxy'])->name('image.proxy'); Tidak ada throttle/middleware pembatas. Karena controller melakukan external HTTP fetch dan (saat ini) caching, endpoint ini bisa disalahgunakan untuk melakukan banyak permintaan ke eksternal dan/atau mengisi cache/storage dengan file besar. Dampak performa: peningkatan latency, outbound bandwidth, dan beban pada server (I/O, CPU), serta biaya jika banyak outbound traffic.
Kode: Route::get('/image-proxy', [ImageProxyController::class, 'proxy'])->name('image.proxy');
Dampak: Abuse (bot) dapat menghasilkan ratusan/ ribuan fetch per menit; tiap fetch memicu HTTP out, memori, dan mungkin cache writes → service degradation. Estimasi: without throttle, a single small bot could generate hundreds req/s causing CPU/IO spike and bandwidth surge.
Fix: Tambahkan throttle middleware dan/atau auth and stricter validation/whitelisting. Contoh:

Route::get('/image-proxy', [ImageProxyController::class, 'proxy'])
    ->middleware('throttle:30,1') // 30 requests per minute per IP
    ->name('image.proxy');

Selain itu, implementasikan domain allowlist inside controller and reject non-whitelisted hosts early. Combine with monitoring/alerting on outbound request rates.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] 🐛 Bug: Public route for image proxy added without rate-limiting/throttling or auth

Kode: Route::get('/image-proxy', [ImageProxyController::class, 'proxy'])->name('image.proxy');

Skenario: Route baru adalah endpoint publik. Bersama dengan kemampuan proxying, attacker dapat melakukan mass-abuse (flood requests), membuat server melakukan banyak outbound requests, atau gunakan endpoint untuk large downloads causing bandwidth/cost/resource exhaustion. Karena tidak ada throttle middleware atau auth, endpoint sangat mudah disalahgunakan.

Dampak: Denial of Service (lokal atau upstream), increased outbound bandwidth and costs, exploitation for SSRF scans.

Fix: Tambahkan throttle middleware (atau authentication/authorization), misal Laravel throttle, atau batasi pada route group. Contoh perbaikan minimal (tambahkan middleware throttle):

Route::get('/image-proxy', [ImageProxyController::class, 'proxy'])
    ->middleware('throttle:60,1') // max 60 requests per minute per IP
    ->name('image.proxy');

Atau jika endpoint hanya untuk internal/frontend usage, pertimbangkan:

  • Mengamankan dengan auth middleware (auth:sanctum / web).
  • Menambahkan custom middleware yang memeriksa referer/CSRF token dan domain allowlist.

Expand Down Expand Up @@ -461,3 +462,6 @@
Route::get('/statistik-keluarga', [PresisiController::class, 'keluarga'])->name('presisi.keluarga');
Route::get('/module/kesehatan/{id}', [PresisiController::class, 'kesehatan'])->name('presisi.kesehatan');
Route::get('/statistik-kesehatan', [PresisiController::class, 'kesehatan'])->name('presisi.kesehatan');

// Image proxy route with rate limiting
Route::middleware('throttle:30,1')->get('/image-proxy', [ImageProxyController::class, 'proxy'])->name('image.proxy');
135 changes: 135 additions & 0 deletions tests/Feature/Http/Controllers/ImageProxyControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

namespace Tests\Feature\Http\Controllers;

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Tests\BaseTestCase;

class ImageProxyControllerTest extends BaseTestCase
{
use DatabaseTransactions;

public function setUp(): void
{
parent::setUp();
// Clear cache before each test
Cache::flush();
// Spy on Log facade to allow logging without strict expectations
Log::spy();
}

/**
* Test proxy with invalid URL.
*/
public function test_proxy_with_invalid_url()
{
$response = $this->get('/image-proxy?url=invalid-url');

$response->assertStatus(400);
}

/**
* Test proxy with empty URL.
*/
public function test_proxy_with_empty_url()
{
$response = $this->get('/image-proxy');

$response->assertStatus(400);
}

/**
* Test proxy with valid URL but non-image content type.
*/
public function test_proxy_with_non_image_content()
{
$url = 'https://example.com/image.jpg';

Http::fake([
$url => Http::response('not an image', 200, ['Content-Type' => 'text/html']),
]);

$response = $this->get('/image-proxy?url=' . urlencode($url));

$response->assertStatus(400);
}

/**
* Test proxy with successful image fetch.
*/
public function test_proxy_with_successful_image_fetch()
{
$url = 'https://example.com/image.jpg';
$imageContent = 'fake image data';
$contentType = 'image/jpeg';

Http::fake([
$url => Http::response($imageContent, 200, ['Content-Type' => $contentType]),
]);

$response = $this->get('/image-proxy?url=' . urlencode($url));

$response->assertStatus(200)
->assertHeader('Content-Type', $contentType);

$this->assertEquals($imageContent, $response->getContent());
}

/**
* Test proxy serves from cache.
*/
public function test_proxy_serves_from_cache()
{
$url = 'https://example.com/image.jpg';
$imageContent = 'cached image data';
$contentType = 'image/png';
$cacheKey = 'image_proxy_' . md5($url);

// Manually set cache
Cache::put($cacheKey, ['content' => $imageContent, 'content_type' => $contentType], 3600);

$response = $this->get('/image-proxy?url=' . urlencode($url));

$response->assertStatus(200)
->assertHeader('Content-Type', $contentType);

$this->assertEquals($imageContent, $response->getContent());
}

/**
* Test proxy with failed external request.
*/
public function test_proxy_with_failed_external_request()
{
$url = 'https://example.com/image.jpg';

Http::fake([
$url => Http::response(null, 404),
]);

$response = $this->get('/image-proxy?url=' . urlencode($url));

$response->assertStatus(404);
}

/**
* Test proxy with exception during fetch.
*/
public function test_proxy_with_exception_during_fetch()
{
$url = 'https://example.com/image.jpg';

Http::fake([
$url => function () {
throw new \Exception('Network error');
},
]);

$response = $this->get('/image-proxy?url=' . urlencode($url));

$response->assertStatus(404);
}
}