-
Notifications
You must be signed in to change notification settings - Fork 13
fix: gambar logo desa pada website desa aktif #1038
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
edeb893
b00f14b
c7f39da
04c844e
1ff1505
63deddf
c55e531
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
|
|
||
| $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() | ||
| ]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: 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:
|
||
| abort(404, 'Image not found'); | ||
| } | ||
|
|
||
| $content = $response->body(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] ⚡ Performance: Loading full response body into memory and caching binary blobs // 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_BYTESAtau 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); | ||
| } | ||
| } | ||
| 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), | ||
| ]; |
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] ⚡ Performance/Security: Public image-proxy route without rate limiting 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: 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:
|
||
|
|
@@ -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'); | ||
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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):
Catatan: