proms-imageへのイメージ所有権検証の適用

proms-imageへのイメージ所有権検証の適用

-imageId単独取得の問題発見からcineroomIdベースのアクセス制御とdevelop/stg検証まで-

1. 適用の背景

今回の作業は、proms-imageプロジェクトで画像リソースのアクセス権限を点検する過程で始まりました。最初は、特定の画面で画像が正常に表示されるか、アップロードと取得に問題がないかを確認する程度の作業だと考えていました。しかし実際に確認したところ、画像の所有病院情報とリクエスト元の病院情報が一致していなくても、画像を取得できる問題が再現しました。

proms-imageは、告知事項、病院登録画像、問診画像、AMISアンケート様式画像など、複数の機能で共通して使用される画像の保存および取得を担当しています。そのため、単にある画面で画像が正常に表示されるだけでは不十分でした。画像がどの病院またはどのcineroomに属するリソースなのかを確認し、リクエスト元がそのリソースを閲覧できるかどうかまで併せて検証する必要がありました。

本稿では、実際の業務で発見した画像所有権の検証漏れをどのように再現し、どのコードパスを修正し、developとstg環境でどのように検証したかをまとめます。内部識別値や実際のサービスパスは説明に必要な範囲でのみ使用し、核心はセキュリティ問題を発見して補完したプロセスに置きます。

2. 問題の状況:imageId単独取得の危険性

問題の核心は、画像取得時にimageIdだけでリソースを検索するパスが存在していた点です。imageIdはUUID形式のため任意に推測することは容易ではありませんが、内部レスポンスや画面HTML、ログ、他のAPIレスポンスを通じて知ることができる値です。そのため、有効なトークンを持つユーザーがimageIdを知っていれば、自分の病院に属していない画像も取得できる危険性がありました。

既存の構造を単純化すると、次のようになります。

// 기존 조회 흐름 예시
public ImageFile findImageFile(String imageId) {
    return imageFileStore.retrieve(imageId);
}

public ImageFile retrieve(String imageId) {
    return imageFileJpaRepository.findById(imageId)
            .map(ImageFileJpo::toDomain)
            .orElse(null);
}

この構造では、DBのimage_fileテーブルにcineroom_idが保存されていても、実際の取得条件には含まれていません。つまり、画像がp-1-c-2の所有として保存されていても、p-1-c-1のユーザーがimageIdを知ってリクエストすれば、その画像が返される可能性があります。

セキュリティの観点では、これは単なる取得バグではなく、リソース所有権の検証漏れです。認証済みのユーザーであっても、すべてのリソースにアクセスできるわけではありません。特に病院または組織単位でデータが分離されるサービスでは、リソースの所有範囲を必ず併せて確認する必要があります。

3. 問題を発見した過程

問題を確認するため、まずdevelop環境でp-1-c-1ユーザーとして告知事項の画像をアップロードしました。アップロード後、DBで該当画像がimage_fileテーブルに保存され、cineroom_idにp-1-c-1が設定されていることを確認しました。

次にセキュリティ検証のため、該当画像rowのcineroom_idだけをp-1-c-2に変更しました。この状態で、同じp-1-c-1ユーザーとして同じ告知事項を再度取得しました。期待される動作は、画像が表示されないか、404でブロックされることでした。しかし、既存のdevelop環境では画像がそのまま表示されました。

ブラウザキャッシュの可能性を排除するため強制リロードを行い、Networkタブで画像取得リクエストが200 OKで応答されることも確認しました。リクエストヘッダーのX-Tenant-Idはp-1-c-1で、DB上の画像のcineroom_idはp-1-c-2でした。この結果から、既存ロジックがリクエスト元のcineroomIdと画像のcineroomIdを比較していないことを明確に確認できました。

-- 재현을 위한 DB 변경 예시
update image_file
set cineroom_id = 'p-1-c-2'
where id = '테스트_image_id';

select id, cineroom_id, original_name, valid_yn
from image_file
where id = '테스트_image_id';

この再現によって、問題の方向性が明確になりました。単に特定の画面で画像が誤って表示される問題ではなく、imageId単独取得が可能なすべてのパスを探す必要がある問題でした。

4. 原因分析:取得パスは1つではありませんでした

最初は、告知事項画像の取得APIだけを修正すればよいと考えられました。しかし、proms-imageは複数の機能から共通して呼び出されており、画像の取得方式も1つではありませんでした。代表的なものとして、imageId基準の取得、imageIdリスト基準の取得、fileId + fileNo基準の取得がありました。

告知事項や病院登録画像のようにimageIdを直接使用するパスは、比較的追跡が容易でした。一方、問診画像とAMISアンケート様式画像には、fileIdとfileNoを基準に既存画像を再利用するパスがありました。このパスは最初の修正範囲から漏れやすく、実際に問診読み込みテスト中、cineroom_idを変更しても画像が引き続き取得されることを確認したことで、追加の補完が必要だと分かりました。

原因分析の結果、大きく2種類の補完が必要でした。

  • 一般画像の取得・変更・削除パスでは、imageId単独の取得を廃止し、imageId + cineroomIdの条件で取得する必要がありました。

  • 問診画像メタデータ取得パスでは、fileId + fileNo単独の取得を廃止し、cineroomId + fileId + fileNoの条件で取得する必要がありました。

  • proms-image-clientを使用するproms-surveyとproms-amc-seoul-adapterも、新しいリクエスト形式に合わせてcineroomIdを渡す必要がありました。

5. 修正方針:リクエストのcineroomIdとリソースのcineroomIdを併せて検証する

修正方針は単純でした。画像リソースを取得する際、imageIdまたはfileId + fileNoだけで検索せず、常にリクエスト元のcineroomIdを取得条件に含めることです。リクエスト元のcineroomIdは、サーバーで管理するユーザーcontextから取得するようにしました。

取得条件を変更すると、他のcineroomに属する画像はDBから取得されません。このとき「権限がない」と「存在しない」を区別して通知すると、リソースの存在有無が露出する可能性があります。そのため、他のcineroomの画像へのアクセスは、実際に存在しない画像と同様に404 Not Foundとして処理する方針を選択しました。

// 수정 방향 예시
String cineroomId = requesterCineroomId();
ImageFile imageFile = imageFileLogic.findImageFile(imageId, cineroomId);

if (imageFile == null) {
    throw new ImageResourceNotFoundException();
}

この方式の利点は、検証の責任範囲が明確になる点です。フロントから渡された任意の値に依存せず、サーバーが認識したリクエスト元のcontextを基準にリソースへのアクセス範囲を制限します。また、DB取得段階でブロックされるため、MinIOのような実ファイルストレージにアクセスする前にリクエストを終了できます。

6. 一般画像の取得・変更・削除パスの補完

一般画像のパスでは、repository、store、domain logic、feature load/flowの各レイヤーを併せて修正しました。核心は、既存のfindByIdまたはfindByIdInによる取得を、findByIdAndCineroomId、findByIdInAndCineroomIdの条件に変更することでした。

// Repository 조회 조건 추가 예시
Optional<ImageFileJpo> findByIdAndCineroomId(String id, String cineroomId);

List<ImageFileJpo> findByIdInAndCineroomId(Collection<String> ids, String cineroomId);

取得ロジックでは、リクエスト元のactiveCineroomIdを取得した後、その値とimageIdを併せて渡しました。変更・削除パスにも同じ基準を適用しました。単純な取得だけをブロックし、変更・削除をimageIdだけで処理すると、依然として他のリソースを変更または削除できてしまうためです。

// Domain logic 예시
@Transactional(readOnly = true)
public ImageFile findImageFile(String imageId, String cineroomId) {
    return imageFileStore.retrieveByIdAndCineroomId(imageId, cineroomId);
}

@Transactional(readOnly = true)
public List<ImageFile> findByIdIn(Collection<String> ids, String cineroomId) {
    return imageFileStore.retrieveByIdInAndCineroomId(ids, cineroomId);
}

この過程で、既存のシステムデフォルト画像のfallbackパスは慎重に扱いました。すべてのパスに無条件でcontext検証を追加すると、テンプレート、システムデフォルト画像、外部または共通の呼び出しパスに影響する可能性があるためです。そのため、一般ユーザー画像の取得・変更・削除パスを中心に修正し、共通テンプレートパスについては実際の呼び出し意図と影響範囲を切り分けました。

7. activeCineroomIdがないリクエストへの防御処理

テスト中、別の問題も発見しました。ブラウザのログインまたはtenant contextが不整合な状態で画像アップロードをリクエストすると、X-Tenant-Idが正常に生成されず、サーバーcontextのactiveCineroomIdが空になる場合がありました。このとき既存コードはOptional.get()を直接呼び出していたため、NoSuchElementExceptionが発生し、結果として500 Internal Server Errorが返されていました。

この問題はH-2所有権検証と直接同じ問題ではありませんが、同じ作業中に発見した防御処理の対象でした。ユーザーにとって分かりにくい500エラーの代わりに、病院選択情報を確認できないことを明確に伝えるメッセージを返す専用例外を追加しました。

public class ActiveCineroomNotFoundException extends RuntimeException {
    public ActiveCineroomNotFoundException() {
        super("병원 선택 정보가 확인되지 않습니다. 다시 로그인 후 이용해 주세요.");
    }
}
private String requesterCineroomId() {
    PromsUserContext context = StageContextHolder.getContext();
    if (context == null || context.getActiveCineroomId().isEmpty()) {
        throw new ActiveCineroomNotFoundException();
    }

    return context.getActiveCineroomId().get();
}

例外処理はproms-image専用handlerで処理するようにしました。common-coreの共通例外処理ロジックを変更すると他のサービスにまで影響する可能性があるため、まずはproms-imageで必要な例外だけを捕捉する構成にしました。

8. 問診画像のfileId + fileNo取得パスの補完

告知事項と病院登録画像については、imageIdベースの取得をブロックすれば解決できました。しかし、問診の読み込みでは問題がもう一度明らかになりました。問診画像の取得パスはimageIdではなく、fileIdとfileNoを基準に既存画像を検索していました。この場合、image_file.cineroom_idをp-1-c-2に変更しても、fileId + fileNoが一致すれば既存rowが再利用される可能性がありました。

これを解決するため、proms-image-clientのClientFindImageByFileIdAndFileNoQueryにcineroomIdフィールドを追加し、サーバーの取得条件もcineroomId + fileId + fileNoに変更しました。

public class ClientFindImageByFileIdAndFileNoQuery {
    private String cineroomId;
    private String fileId;
    private String fileNo;
}
// Repository 조회 조건 추가 예시
Optional<ImageFileJpo> findByCineroomIdAndFileIdAndFileNo(
        String cineroomId,
        String fileId,
        String fileNo
);

この変更はproms-imageだけを修正して終わる作業ではありませんでした。proms-image-clientを使用するサービスも、新しいリクエスト形式に合わせてcineroomIdを渡す必要がありました。実際の影響範囲を確認した結果、proms-surveyとproms-amc-seoul-adapterが該当clientを使用しており、両サービスとも修正が必要でした。

proms-surveyでは、問診詳細の取得過程でtargetCineroomIdも併せて渡すように変更しました。proms-amc-seoul-adapterでは、AMISアンケート様式の取得過程で病院のcineroomIdも併せて渡すように変更しました。

// proms-survey 호출부 예시
imageLoadProxyService.findImageByFileIdAndFileNo(
        targetCineroomId,
        fileId,
        fileNo
);
// proms-amc-seoul-adapter 호출부 예시
String cineroomId = HospitalTenant.CINEROOM.getValue();
imageLoadProxyService.findImageByFileIdAndFileNo(
        cineroomId,
        vo.getImageFileId(),
        vo.getImageFileNo()
);

9. clientモジュールのデプロイと連携サービスへの影響範囲

今回の修正で最も注意が必要だったのは、proms-image-clientのデプロイでした。proms-surveyとproms-amc-seoul-adapterは、Nexusにデプロイされたcom.proms:proms-image-clientライブラリを使用していました。そのため、client DTOのフィールドとコンストラクタが変わると、そのclientバージョンを使用するサービスのコンパイルにも影響する可能性がありました。

既存バージョンを上書きする方式は選択しませんでした。同じNexusをmainとdevelopが参照していたため、既存artifactの内容を変更すると、意図していないサービスが新しいDTOを受け取る可能性がありました。そこでproms-image-clientのbaseVersionを1.0.4に上げ、releaseバージョンがNexusに正常にデプロイされたことを確認したうえで、proms-surveyとproms-amc-seoul-adapterでそのバージョンを明示的に使用するように変更しました。

// 연동 서비스 build.gradle 예시
implementation 'com.proms:proms-image-client:1.0.4' 

デプロイ前には、各リポジトリで呼び出し箇所を検索しました。findImageByFileIdAndFileNo(fileId, fileNo)形式の2引数呼び出しが残っていると、新しいclient適用時にコンパイルエラーが発生するか、セキュリティ検証が漏れる可能性があるためです。

rg "findImageByFileIdAndFileNo\(" -n .
rg "new ClientFindImageByFileIdAndFileNoQuery" -n .
rg "proms-image-client" -n .

検索結果に基づき、proms-surveyとproms-amc-seoul-adapterの呼び出し箇所をすべて3引数に変更し、build.gradleでproms-image-client 1.0.4を使用するよう整理しました。

10. developおよびstgでの検証結果

修正後、develop環境とstg環境で順番に検証しました。検証は単純な正常取得だけでなく、DBでテスト対象画像のcineroom_idを別の病院の値に変更した後、取得がブロックされるかを確認する方式で実施しました。DBの変更はすべてidを基準に1件だけ行い、テスト後すぐに元へ戻しました。

10.1 告知事項画像の検証

告知事項画像は、p-1-c-1ユーザーでアップロードした後、正常に表示されることを確認しました。その後、該当画像rowのcineroom_idをp-1-c-2に変更し、同じユーザーで取得したところ、画像が表示されないことを確認しました。元に戻した後は、再び正常に表示されました。

10.2 病院登録画像の検証

病院登録画像またはtenant画像も同じ方式で検証しました。正常に取得できることを先に確認した後、image_file.cineroom_idをp-1-c-2に変更しました。その結果、p-1-c-1ユーザーとしては画像を取得できず、再びp-1-c-1に戻すと画像が正常に取得されました。

10.3 proms-survey 問診読み込み画像の検証

proms-surveyでは、問診読み込み画面で問診に含まれる画像が正常に取得できるかをまず確認しました。その後、該当画像のcineroom_idをp-1-c-2に変更したところ、p-1-c-1ユーザー基準の問診取得では該当画像が表示されませんでした。元に戻した後は、再び正常に取得できました。これにより、問診画像の取得経路にもcineroomId条件が適用されていることを確認しました。

10.4 proms-amc-seoul-adapter AMISアンケートフォーム画像の検証

proms-amc-seoul-adapterの場合、画面から直接確認することが難しかったため、PostmanでAMISアンケートフォーム取得APIを呼び出しました。この経路では、画像が存在しない場合、AMISの元ファイルを再取得して新しい画像rowを生成できます。そのため、「画像が表示されないか」ではなく、「p-1-c-2に変更した既存の画像rowが再利用されるか」を基準に検証しました。

テストの結果、既存のp-1-c-1画像rowのcineroom_idをp-1-c-2に変更した後、同じfileId + fileNoで再取得したところ、既存のrowは再利用されませんでした。代わりに、p-1-c-1基準の新しい画像rowが生成されました。これは、他のcineroomの画像が再利用されず、リクエストされたcineroomを基準に再処理されたということなので、正常な動作と判断しました。テスト後は新しく生成されたrowを削除し、既存のrowは元に戻しました。

-- adapter 검증 시 판단 기준 예시
-- OLD_IMAGE_ID: 기존 row, cineroom_id를 p-1-c-2로 변경
-- 동일 API 재호출 후 OLD_IMAGE_ID가 응답이나 재사용 경로에 나타나면 실패
-- OLD_IMAGE_ID가 재사용되지 않고 NEW_IMAGE_ID가 p-1-c-1로 생성되면 정상

select id, cineroom_id, file_id, file_no, original_name, valid_yn, registered_on
from image_file
where file_id = '테스트_file_id'
  and file_no = '테스트_file_no'
order by registered_on desc;

10.5 Clinical画像の検証状況

医療従事者-患者 Clinical画像は、当時は画面へのアクセスが制限されていたため、実際の画面を基準としたテストは実施できませんでした。ただし、コード上では、お知らせや問診画像と同様に、cineroomIdに基づく取得ロジックが適用されていることを確認しました。今後、画面へのアクセスが可能になった場合は、実際のアップロード、取得、および他のcineroomがブロックされるかどうかを追加で検証する予定です。

11. 適用を通じて学んだこと

1つ目に学んだことは、認証と認可を分けて考える必要があるということです。ユーザーが有効なトークンを持っているという事実は「ログイン済みのユーザー」であることを意味するだけで、すべての画像リソースにアクセスできるという意味ではありません。今回の問題は、認証は済んでいたものの、リソースの所有権検証が欠けていたケースでした。

2つ目に学んだことは、共通モジュールの影響範囲を必ず確認する必要があるということです。proms-image-clientは複数のサービスが共同で使用するライブラリでした。そのため、client DTOを変更すると、proms-imageサーバーだけでなく、proms-surveyやproms-amc-seoul-adapterなどの呼び出しサービスも併せて修正する必要がありました。単一プロジェクト内の修正のように見えましたが、実際には複数サービスにまたがる連携変更でした。

3つ目に学んだことは、テスト基準を機能ごとに変える必要があるということです。お知らせや病院登録画像では、「画像が表示されてはならない」が正しい検証基準でした。一方、AMISアンケートフォーム画像は、元ファイルへのfallbackによって新しい画像が生成される可能性があるため、「既存の他cineroomのrowが再利用されてはならない」のほうが正確な基準でした。

4つ目に学んだことは、DBを直接操作するセキュリティ検証では、元に戻す手順がテストと同じくらい重要だということです。developとstgでテストする際は、常に対象rowを先に確認し、idを基準に1件だけ変更し、テスト終了後は直ちに元に戻しました。新しいrowが生成されるケースでは、テストrowを削除または無効化して、環境が汚染されないようにしました。

最後に、小さなログの削除も、デプロイの観点では確認対象になり得るということを改めて感じました。ImageIoConfigのSystem.out.println削除のように、機能ロジックに直接関係しない修正については、全体回帰テストまでは必要ありませんでしたが、新しいpodの起動時に該当する標準出力ログが表示されなくなったことは確認できました。

12. まとめ

今回の作業は、単に画像が正常に表示されるかを確認するところから始まりましたが、実際には画像リソースの所有権検証を全般的に点検する作業となりました。最初はお知らせ画像で問題が再現され、その後、問診読み込みとAMISアンケートフォーム取得の経路まで確認したことで、imageIdに基づく取得だけでなく、fileId + fileNoに基づく再利用経路も併せて改善する必要があることが分かりました。

最終的にproms-imageでは、imageIdまたはfileId + fileNoだけによる単独取得を廃止し、cineroomId条件を含めるように修正しました。proms-image-clientは1.0.4としてデプロイし、これを使用するproms-surveyとproms-amc-seoul-adapterでもcineroomIdを渡すように変更しました。developとstg環境では、お知らせ、病院登録、問診読み込み、AMISアンケートフォーム取得について、正常な取得と、他のcineroomの遮断または再利用されない動作を確認しました。

今回の経験を通じて、セキュリティ問題はコード1行だけを見て判断するのが難しいということを学びました。同じ画像リソースでも、画面、API、保存方式、fallbackロジックによってアクセス経路が異なる可能性があります。そのため、セキュリティ修正では「問題が発生した画面」だけを修正するのではなく、同じリソースに到達するすべての経路を追跡し、それぞれの経路に合った検証基準を設定する必要があります。

今後、同様の共通リソース取得機能を実装する際は、最初からリソース所有者、リクエスト元のcontext、取得条件、失敗時のレスポンスポリシー、clientへの影響範囲まで併せて確認する習慣を持つべきだと感じました。今回の作業は、画像のセキュリティ問題を解決した経験であると同時に、MSA環境で共通clientの変更と複数サービスの検証をどのように管理すべきかを学んだ事例でした。

付録。検証チェックリスト

  • お知らせ画像:正常な取得、他のcineroomへの変更時の遮断、元に戻した後の正常な取得を確認しました。

  • 病院登録画像:正常な取得、他のcineroomへの変更時の遮断、元に戻した後の正常な取得を確認しました。

  • proms-survey問診画像:cineroom_id変更時に非表示となり、元に戻した後は正常に表示されることを確認しました。

  • proms-amc-seoul-adapter AMISアンケートフォーム画像:他のcineroomのrowが再利用されず、リクエストされたcineroomを基準に新しいrowが生成されることを確認しました。

  • proms-image-client:1.0.4 releaseバージョンがNexusにデプロイされたことを確認しました。

  • proms-surveyとproms-amc-seoul-adapter:proms-image-client 1.0.4の使用および3つの引数による呼び出し形式を確認しました。

  • developおよびstg DBで変更したテストデータは元に戻しました。

wade

Site footer