fix(ecommerce): 숨김 리뷰 이미지의 관리자 썸네일을 한시 서명 URL 로 복구

KVE-2026-1914 S-2 게이트가 숨김(HIDDEN) 리뷰 이미지를 무조건 404 차단해,
관리자 리뷰 화면의 <img> 썸네일(인증 헤더를 실을 수 없는 렌더 경로)이 깨졌다.
게시판·페이지 첨부와 동일 설계로, 게이트를 통과한 관리자 응답의 직렬화
시점에만 30분 한시 서명 download URL 을 발급하고 서빙 엔드포인트가 유효
서명을 허용한다. 무서명·변조·만료 요청 게이트와 전시중 리뷰의 무서명
공개 URL(직접 URL/CDN 포함)은 종전과 동일하다.

숨김 리뷰가 이미지와 함께 직렬화되는 경로는 관리자 응답뿐이므로(공개
목록은 VISIBLE 필터, 작성 직후는 이미지 미로드) 서명 발급이 곧 게이트
통과 자격의 위임이다.
This commit is contained in:
HeuJung
2026-08-18 10:54:26 +09:00
parent 04f1066e66
commit 932866da8f
10 changed files with 261 additions and 9 deletions
@@ -9,7 +9,7 @@
### Security
- 비밀 상품 문의의 내용이 작성자·관리자가 아닌 사람에게도 노출되던 문제를 막았습니다. 문의 목록은 게시글의 비밀 여부를 서버에서 확인해, 열람 권한이 없는 요청에는 내용·제목·답변·첨부를 가립니다. (목록의 '비밀글 숨김' 옵션과 무관하게 서버가 요청자 신원으로 노출 여부를 결정합니다.) (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
- 관리자가 숨긴 리뷰의 이미지가 주소만 알면 계속 조회되던 문제를 수정했습니다. 이제 숨김 처리된 리뷰의 이미지는 제공되지 않습니다. 리뷰가 삭제되어 상태를 확인할 수 없는 이미지도 함께 차단합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
- 관리자가 숨긴 리뷰의 이미지가 주소만 알면 계속 조회되던 문제를 수정했습니다. 이제 숨김 처리된 리뷰의 이미지는 제공되지 않습니다. 리뷰가 삭제되어 상태를 확인할 수 없는 이미지도 함께 차단합니다. 관리자 리뷰 화면에 표시되는 숨김 리뷰의 이미지 썸네일은 한시적으로만 유효한 서명 주소로 전달되어, 보호를 유지한 채 정상 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
- 상품·주문·리뷰·브랜드·쿠폰·배송정책·추가배송비 관리에 담당 범위 제한을 적용했습니다. 이 권한들은 "본인이 등록한 것만" 처럼 범위를 좁혀 위임할 수 있는데, 그 제한이 실제로는 어느 관리 화면에서도 적용되지 않았습니다. 목록에서 여러 건을 한 번에 처리하는 일괄 기능뿐 아니라, 항목을 하나씩 수정·삭제하는 화면에서도 마찬가지였습니다. 이제 모든 경로에서 대상마다 범위를 확인하며, 일괄 기능은 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. 범위 제한 없이 위임받은 관리자의 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919)
- 리뷰 작성 시 대상 상품을 요청에 담긴 값이 아니라 실제 구매한 주문 옵션에서 확정하도록 바로잡았습니다. 이전에는 자신의 구매 내역을 근거로 삼되 다른 상품을 대상으로 지정해 리뷰를 남길 수 있었습니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
- 다운로드 대상이 아닌 쿠폰(자동 발급·관리자 직접 발급)이나 이미 유효기간이 지난 쿠폰을, 목록에 보이지 않더라도 주소만 알면 직접 내려받을 수 있던 문제를 수정했습니다. 이제 다운로드 발급 조건과 유효기간을 발급 시점에 함께 확인합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
@@ -73,7 +73,7 @@ Content-Disposition: attachment; filename="review.jpg"
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 404 | Not Found | 해시에 해당하는 리뷰 이미지가 없거나(`findByHash()` → null), 레코드는 있으나 스토리지에 실제 파일이 없는 경우, **또는 이미지가 속한 리뷰가 노출(VISIBLE) 상태가 아닌 경우**(숨김·블라인드·대기 리뷰의 이미지는 존재를 감추기 위해 동일하게 404) (`messages.reviews.image_not_found`) |
| 404 | Not Found | 해시에 해당하는 리뷰 이미지가 없거나(`findByHash()` → null), 레코드는 있으나 스토리지에 실제 파일이 없는 경우, **또는 이미지가 속한 리뷰가 노출(VISIBLE) 상태가 아니면서 유효한 서명도 없는 경우**(숨김·블라인드·대기 리뷰의 이미지는 존재를 감추기 위해 동일하게 404) (`messages.reviews.image_not_found`) |
<!-- @generated:end -->
@@ -81,4 +81,6 @@ Content-Disposition: attachment; filename="review.jpg"
이미지 서빙은 **부모 리뷰가 노출(VISIBLE) 상태일 때로 한정**됩니다(KVE-2026-1914). 숨김·블라인드·대기 상태 리뷰의 이미지는 해시가 유효해도 서빙되지 않고 404 를 반환합니다 — 본문이 감춰진 리뷰의 이미지가 해시만으로 노출되는 것을 막고, 존재 자체를 드러내지 않기 위해 "이미지 없음"과 동일한 404 로 응답합니다. 노출 상태 리뷰의 이미지는 별도 인증 없이 공개 접근할 수 있습니다.
브라우저 `<img src>` 는 Authorization 헤더를 실을 수 없으므로, 숨김 리뷰의 이미지를 싣는 관리자 응답(리뷰 목록·상세)의 `download_url` 에는 `expires`·`signature` 쿼리가 붙은 **한시 서명 URL** 이 직렬화되며, 이 엔드포인트는 유효한 서명을 상태 게이트와 동등한 자격으로 허용합니다. 서명이 변조·만료된 요청은 종전과 동일하게 404 로 차단됩니다. 노출(VISIBLE) 리뷰의 `download_url` 은 무서명 공개 URL(직접 URL/CDN 또는 이 API 경로)을 유지합니다.
@@ -6,6 +6,7 @@ use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AuthBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Ecommerce\Exceptions\ReviewImageUploadLimitException;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\UploadReviewImageRequest;
@@ -122,12 +123,21 @@ class ReviewImageController extends AuthBaseController
/**
* 리뷰 이미지 다운로드 (해시 기반 공개 서빙)
*
* 숨김(HIDDEN) 리뷰의 이미지는 서비스의 상태 게이트가 차단하되, 게이트를 통과한
* 응답 직렬화(ProductReviewResource)가 발급한 한시 서명 URL 은 동등한 자격으로
* 허용한다 — <img> 는 Authorization 헤더를 실을 수 없는 렌더 경로이기 때문이다.
*
* @param Request $request HTTP 요청 객체 (한시 서명 검증용)
* @param string $hash 이미지 해시 (12자)
* @return StreamedResponse|JsonResponse 이미지 스트림 또는 404 응답
*/
public function download(string $hash): StreamedResponse|JsonResponse
// audit:allow controller-base-request-injection reason: GET 이미지 서빙. 요청 URL 의 한시 서명 검증(hasValidSignature)만 수행. 검증할 body 없음(hash 는 라우트 파라미터)
public function download(Request $request, string $hash): StreamedResponse|JsonResponse
{
$response = $this->imageService->download($hash);
$response = $this->imageService->download(
$hash,
signatureVerified: $request->hasValidSignature(absolute: false)
);
if (! $response) {
return ResponseHelper::moduleError(
@@ -4,12 +4,42 @@ namespace Modules\Sirsoft\Ecommerce\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Modules\Sirsoft\Ecommerce\Models\ProductReviewImage;
/**
* 리뷰 이미지 리소스
*/
class ProductReviewImageResource extends BaseApiResource
{
/**
* 숨김 리뷰 이미지의 <img> 렌더용 한시 서명 download URL 발급 여부.
*
* 게이트(관리자 권한 미들웨어)를 통과한 응답의 직렬화 시점에만 참이 된다 —
* 전시중(VISIBLE) 리뷰는 무서명 공개 URL 을 유지한다 (CDN·캐시 안정성).
*/
private bool $signedDownload = false;
/**
* 리뷰 이미지 목록을 리소스 배열로 변환합니다.
*
* @param iterable<int, ProductReviewImage>|null $images 리뷰 이미지 목록
* @param bool $signedDownload 한시 서명 download URL 발급 여부 (숨김 리뷰 전용)
* @return array<int, self>
*/
public static function collectionFor($images, bool $signedDownload = false): array
{
return Collection::make($images ?? [])
->map(function ($image) use ($signedDownload) {
$resource = new self($image);
$resource->signedDownload = $signedDownload;
return $resource;
})
->values()
->all();
}
/**
* 리소스를 배열로 변환
*
@@ -23,7 +53,9 @@ class ProductReviewImageResource extends BaseApiResource
'review_id' => $this->review_id,
'hash' => $this->hash,
'original_filename' => $this->original_filename,
'download_url' => $this->download_url,
'download_url' => $this->signedDownload
? $this->resource->signedDownloadUrl()
: $this->download_url,
'mime_type' => $this->mime_type,
'file_size' => $this->file_size,
'width' => $this->width,
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Enums\ReviewStatus;
/**
* 상품 리뷰 리소스
@@ -55,8 +56,14 @@ class ProductReviewResource extends BaseApiResource
'status_label' => $this->status->label(),
'status_badge_color' => $this->status->badgeColor(),
// 이미지
'images' => ProductReviewImageResource::collection($this->whenLoaded('images')),
// 이미지 — 숨김(HIDDEN) 리뷰의 이미지는 한시 서명 download URL 로 직렬화한다.
// 숨김 리뷰가 이미지와 함께 직렬화되는 경로는 관리자(권한 미들웨어) 응답뿐이므로
// (공개 목록은 VISIBLE 필터, 작성 직후는 이미지 미로드) 서명 발급이 곧
// 게이트 통과 자격의 위임이다. 전시중 리뷰는 무서명 공개 URL 유지.
'images' => $this->whenLoaded('images', fn () => ProductReviewImageResource::collectionFor(
$this->images,
signedDownload: $this->status === ReviewStatus::HIDDEN
)),
// 첨부 개수는 목록 쿼리의 DB 집계(`withCount('images as image_count')`)를 우선 쓴다.
// 관계 기반 계산만 두면, 이미지를 로드하지 않는 목록에서 개수까지 함께 사라진다.
'image_count' => $this->image_count !== null
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\URL;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductReviewImageFactory;
use Modules\Sirsoft\Ecommerce\Models\Concerns\HasDirectAssetUrl;
@@ -113,4 +114,37 @@ class ProductReviewImage extends Model
{
return '/api/modules/sirsoft-ecommerce/review-image/'.$this->hash;
}
/**
* 숨김(HIDDEN) 리뷰 이미지의 <img> 렌더용 한시 서명 다운로드 URL 을 반환합니다.
*
* 브라우저 <img src> 는 Authorization 헤더를 실을 수 없으므로, 숨김 리뷰의
* 이미지를 권한자 화면(관리자 리뷰 목록·상세)에 표시할 때는 한시 서명 URL 을
* 발급한다. 서명 URL 은 게이트(관리자 권한 미들웨어)를 통과한 응답 직렬화
* 시점에만 발급되고(ProductReviewResource), 서빙 엔드포인트가 유효 서명을
* 무인증 허용한다 — 무서명 요청의 상태 게이트는 종전과 동일하다.
*
* 직접 URL(CDN)과 무관하게 항상 API 서빙 경로에 서명한다 — 게이트가 있는
* 경로는 API 서빙뿐이고, 직접 URL 가용 여부로 분기하면 저장 모드에 따라
* 관리자 화면 동작이 갈린다.
*
* @return string 한시 서명 다운로드 URL (상대경로)
*/
public function signedDownloadUrl(): string
{
return URL::temporarySignedRoute(
'api.modules.sirsoft-ecommerce.review-image.download',
now()->addMinutes(self::SIGNED_DOWNLOAD_TTL_MINUTES),
['hash' => $this->hash],
absolute: false
);
}
/**
* 서명 다운로드 URL 의 유효 시간(분).
*
* 관리자 리뷰 화면이 열려 있는 동안 썸네일이 유지될 만큼 길고,
* URL 유출 시 노출 창을 좁힐 만큼 짧은 값.
*/
public const SIGNED_DOWNLOAD_TTL_MINUTES = 30;
}
@@ -155,10 +155,17 @@ class ProductReviewImageService
/**
* 이미지 다운로드 응답 생성
*
* $signatureVerified 는 요청 URL 의 한시 서명이 검증된 경우다 — 서명은
* 숨김 리뷰 게이트(관리자 권한 미들웨어)를 통과한 응답 직렬화
* (ProductReviewResource)만 발급하므로, 검증된 서명은 그 게이트 통과 자격의
* 위임으로 보고 상태 게이트를 재적용하지 않는다 (<img> 는 인증 헤더를 실을 수
* 없는 렌더 경로).
*
* @param string $hash 이미지 해시 (12자)
* @param bool $signatureVerified 한시 서명 검증 통과 여부
* @return StreamedResponse|null 다운로드 응답 (없으면 null)
*/
public function download(string $hash): ?StreamedResponse
public function download(string $hash, bool $signatureVerified = false): ?StreamedResponse
{
$image = $this->findByHash($hash);
@@ -174,7 +181,7 @@ class ProductReviewImageService
// "부모가 사라진 이미지는 무조건 공개" 가 된다 — 첨부 게이트가 채택한 방향과 반대다.
// 현재는 리뷰 삭제 시 이미지도 함께 삭제돼 여기까지 오지 않지만, 그 두 서비스가
// 계속 동기화된다는 암묵 불변식에 보안을 걸지 않는다.
if (! $image->review || $image->review->status !== ReviewStatus::VISIBLE) {
if (! $signatureVerified && (! $image->review || $image->review->status !== ReviewStatus::VISIBLE)) {
return null;
}
@@ -7,6 +7,7 @@ use Modules\Sirsoft\Ecommerce\Enums\ReviewStatus;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductReview;
use Modules\Sirsoft\Ecommerce\Models\ProductReviewImage;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
@@ -574,4 +575,61 @@ class ProductReviewControllerTest extends ModuleTestCase
// Then
$response->assertForbidden();
}
// ========================================
// 이미지 download URL 직렬화 (숨김 리뷰 <img> 썸네일 렌더 계약)
// ========================================
/**
* 숨김(HIDDEN) 리뷰의 관리자 상세 응답은 이미지에 한시 서명 download URL 을
* 직렬화한다 — <img> 는 인증 헤더를 실을 수 없어, 무서명 URL 이면 관리자
* 화면의 썸네일이 서빙 게이트(404)에 깨진다.
*
* @scenario resource=review_image, parent_state=restricted
*
* @effects admin_review_serializes_signed_download_url_for_hidden_review
*/
#[Test]
public function test_admin_detail_serializes_signed_download_url_for_hidden_review(): void
{
// Given: 숨김 리뷰 + 이미지
$review = $this->createReview(['status' => ReviewStatus::HIDDEN->value]);
ProductReviewImage::factory()->create(['review_id' => $review->id]);
// When
$downloadUrl = $this->actingAs($this->adminUser)
->getJson("{$this->apiBase}/{$review->id}")
->assertOk()
->json('data.images.0.download_url');
// Then: 서명 쿼리가 실린 URL
$this->assertIsString($downloadUrl);
$this->assertStringContainsString('signature=', $downloadUrl, '숨김 리뷰 이미지는 서명 download URL 로 직렬화되어야 합니다');
}
/**
* 전시중(VISIBLE) 리뷰의 이미지 download URL 은 종전과 동일한 무서명 URL 이다
* (공개 콘텐츠에 만료성 URL 이 섞이는 회귀 방지 — CDN·캐시 안정성).
*
* @scenario resource=review_image, parent_state=public
*
* @effects visible_review_serializes_plain_download_url
*/
#[Test]
public function test_admin_detail_serializes_plain_download_url_for_visible_review(): void
{
// Given: 전시중 리뷰 + 이미지
$review = $this->createReview();
$image = ProductReviewImage::factory()->create(['review_id' => $review->id]);
// When
$downloadUrl = $this->actingAs($this->adminUser)
->getJson("{$this->apiBase}/{$review->id}")
->assertOk()
->json('data.images.0.download_url');
// Then: 기존 무서명 URL 그대로 (직접 URL 또는 API 폴백)
$this->assertSame($image->download_url, $downloadUrl);
$this->assertStringNotContainsString('signature=', (string) $downloadUrl);
}
}
@@ -8,6 +8,7 @@ use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\URL;
use Mockery;
use Mockery\MockInterface;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
@@ -410,4 +411,92 @@ class ReviewImageControllerTest extends ModuleTestCase
// Then: VISIBLE 리뷰 이미지는 상태 게이트를 통과(404 아님)
$this->assertNotSame(404, $response->getStatusCode(), 'VISIBLE 리뷰 이미지는 서빙되어야 합니다');
}
// ==========================================
// 서명 download URL (숨김 리뷰 <img> 썸네일 렌더 경로)
//
// 브라우저 <img src> 는 Authorization 헤더를 실을 수 없어, 숨김 게이트 도입 후
// 관리자 리뷰 화면의 숨김 리뷰 이미지 썸네일이 무인증 요청 → 404 로 깨졌다.
// 게이트를 통과한 응답 직렬화(ProductReviewResource)가 한시 서명 URL 을 발급하고,
// 서빙 엔드포인트가 유효 서명을 허용한다 — 무서명 게이트는 종전과 동일하다.
// (게시판·페이지 첨부 preview 와 동일 설계)
// ==========================================
/**
* 숨김 리뷰 이미지를 만들고 서명 download URL 을 돌려주는 헬퍼
*
* @param int $minutes 유효 시간(분, 음수면 만료된 URL)
* @return string 상대경로 서명 URL
*/
private function hiddenImageSignedUrl(int $minutes = 30): string
{
$hiddenReview = ProductReview::factory()->create([
'product_id' => $this->product->id,
'order_option_id' => $this->orderOption->id,
'user_id' => $this->user->id,
'status' => ReviewStatus::HIDDEN->value,
]);
$image = ProductReviewImage::factory()->create([
'review_id' => $hiddenReview->id,
]);
return URL::temporarySignedRoute(
'api.modules.sirsoft-ecommerce.review-image.download',
now()->addMinutes($minutes),
['hash' => $image->hash],
absolute: false
);
}
/**
* 유효한 한시 서명 download URL 은 무인증(<img>) 요청도 숨김 게이트를 통과한다.
*
* @scenario resource=review_image, parent_state=restricted
*
* @effects hidden_review_image_download_allowed_with_valid_signature
*/
#[Test]
public function test_download_allows_hidden_review_image_with_valid_signature(): void
{
$this->storageMock->allows('response')->andReturn(
new StreamedResponse(fn () => null, 200)
);
$response = $this->get($this->hiddenImageSignedUrl());
$this->assertNotSame(404, $response->getStatusCode(), '유효 서명 URL 은 숨김 게이트를 통과해야 합니다');
}
/**
* 변조된 서명 download URL 은 종전과 동일하게 숨김 게이트에 차단된다 (404).
*
* @scenario resource=review_image, parent_state=restricted
*
* @effects hidden_review_image_download_blocked_with_tampered_signature
*/
#[Test]
public function test_download_blocks_hidden_review_image_with_tampered_signature(): void
{
// signature 쿼리 값의 마지막 8자를 뒤집어 변조한다
$tampered = preg_replace_callback(
'/(signature=)([0-9a-f]+)/',
fn ($m) => $m[1].substr($m[2], 0, -8).strrev(substr($m[2], -8)),
$this->hiddenImageSignedUrl()
);
$this->get($tampered)->assertNotFound();
}
/**
* 만료된 서명 download URL 은 숨김 게이트에 차단된다 (404, 한시성 보장).
*
* @scenario resource=review_image, parent_state=restricted
*
* @effects hidden_review_image_download_blocked_with_expired_signature
*/
#[Test]
public function test_download_blocks_hidden_review_image_with_expired_signature(): void
{
$this->get($this->hiddenImageSignedUrl(minutes: -1))->assertNotFound();
}
}
@@ -27,6 +27,11 @@ description: |
유효 서명을 무인증 허용한다. 무서명 게이트는 종전과 동일하며, 발행 페이지는 무서명
공개 URL 을 유지한다(SEO·캐시 안정성).
review_image 도 같은 결로 관리자 리뷰 화면(목록·상세)의 숨김 리뷰 썸네일이 깨졌다.
숨김 리뷰가 이미지와 함께 직렬화되는 경로는 관리자 응답뿐이므로(공개 목록은 VISIBLE
필터), ProductReviewResource 가 숨김 리뷰의 download_url 을 한시 서명 URL 로 직렬화하고
서빙 엔드포인트가 유효 서명을 허용한다. 전시중 리뷰는 무서명 URL(직접 URL/CDN 포함) 유지.
axes:
resource: [page_attachment, review_image]
parent_state: [public, restricted]
@@ -49,7 +54,15 @@ effects:
- published_page_serializes_plain_preview_url
- hidden_review_image_download_blocked
- visible_review_image_download_still_served
# 리뷰 이미지 서명 download URL 축 — 숨김 리뷰의 관리자 화면 <img> 렌더 경로 (페이지·게시판과 동일 설계)
- hidden_review_image_download_allowed_with_valid_signature
- hidden_review_image_download_blocked_with_tampered_signature
- hidden_review_image_download_blocked_with_expired_signature
- admin_review_serializes_signed_download_url_for_hidden_review
- visible_review_serializes_plain_download_url
test_files:
- modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageAttachmentAccessTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/ReviewImageControllerTest.php
# 숨김 리뷰 이미지 서명 download URL 의 직렬화 축 (관리자 상세 응답)
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ProductReviewControllerTest.php