Apply Image Ownership Verification to proms-image

Apply Image Ownership Verification to proms-image

-From discovering the imageId-only lookup issue to access control based on cineroomId and validation in develop/stg-

1. Background

This work began while reviewing image resource access permissions in the proms-image project. At first, I thought the task would simply involve checking whether images displayed correctly on specific screens and whether uploads and lookups worked without errors. However, during the actual review, we reproduced an issue in which an image could be retrieved even when the hospital owning the image and the requester's hospital did not match.

proms-image is responsible for storing and retrieving images shared across several features, including announcements, hospital registration images, medical questionnaire images, and AMIS survey template images. Therefore, simply confirming that an image displays correctly on one screen was not sufficient. We also had to verify which hospital or cineroom the image belonged to and whether the requester was authorized to view that resource.

This article describes how we reproduced the missing image ownership validation discovered during actual work, which code paths we modified, and how we validated the changes in the develop and stg environments. Internal identifiers and actual service paths are used only where necessary for the explanation; the focus is on the process of identifying and addressing the security issue.

2. Problem: The Risk of imageId-Only Lookups

The core issue was that there was a path that located resources using only imageId when retrieving images. Although imageId is UUID-based and not easy to guess arbitrarily, it can be learned through internal responses, screen HTML, logs, or other API responses. Therefore, if a user with a valid token knew the imageId, they could potentially retrieve an image that did not belong to their hospital.

Simplifying the existing structure, it was as follows.

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

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

In this structure, even though cineroom_id was stored in the DB's image_file table, it was not included in the actual lookup condition. In other words, even if an image was stored as belonging to p-1-c-2, a user from p-1-c-1 could have the image returned by making a request with its imageId.

From a security perspective, this was not simply a lookup bug but a failure to validate resource ownership. Even authenticated users are not automatically authorized to access all resources. In services where data is separated by hospital or organization, the resource's ownership scope must always be verified as well.

3. How the Issue Was Discovered

To investigate the issue, we first uploaded an announcement image in the develop environment using a p-1-c-1 user. After the upload, we confirmed in the DB that the image had been stored in the image_file table and that cineroom_id was set to p-1-c-1.

Next, for security validation, we changed only the cineroom_id of the corresponding image row to p-1-c-2. In this state, we retrieved the same announcement again using the same p-1-c-1 user. The expected behavior was for the image to be hidden or blocked with a 404 response. However, in the existing develop environment, the image was still displayed as before.

To rule out browser caching, we performed a hard refresh and confirmed in the Network tab that the image lookup request returned 200 OK. The X-Tenant-Id in the request header was p-1-c-1, while the image's cineroom_id in the DB was p-1-c-2. This clearly confirmed that the existing logic did not compare the requester's cineroomId with the image's 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';

This reproduction clarified the nature of the issue. It was not merely a problem in which an image was displayed incorrectly on a specific screen; we needed to find every path that allowed imageId-only lookups.

4. Root Cause Analysis: There Was More Than One Lookup Path

At first, it might seem that modifying only the announcement image lookup API would be sufficient. However, proms-image was called as a shared component by multiple features, and there was more than one image lookup method. The main methods were lookup by imageId, lookup by a list of imageIds, and lookup by fileId + fileNo.

Paths that directly used imageId, such as announcement images and hospital registration images, were relatively easy to trace. In contrast, medical questionnaire images and AMIS survey template images had paths that reused existing images based on fileId and fileNo. This path was easy to miss in the initial modification scope. In fact, during medical questionnaire loading tests, we found that the image continued to be retrieved even after changing cineroom_id, confirming that additional remediation was necessary.

The root cause analysis identified two major areas requiring changes.

  • In the general image lookup/update/delete paths, imageId-only lookups had to be removed and replaced with lookups using imageId + cineroomId.

  • In the medical questionnaire image metadata lookup path, fileId + fileNo-only lookups had to be removed and replaced with lookups using cineroomId + fileId + fileNo.

  • proms-survey and proms-amc-seoul-adapter, which use proms-image-client, also had to pass cineroomId according to the new request format.

5. Modification Direction: Validating the Requester's cineroomId Together with the Resource's cineroomId

The modification direction was straightforward. When retrieving an image resource, we would always include the requester's cineroomId in the lookup condition instead of searching only by imageId or fileId + fileNo. The requester's cineroomId was obtained from the user context managed by the server.

Once the lookup condition was changed, images belonging to another cineroom were not retrieved from the DB. Because distinguishing “permission denied” from “not found” could expose whether a resource exists, we chose to handle access to images from another cineroom as 404 Not Found, the same as an image that does not exist.

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

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

The advantage of this approach is that the responsibility for validation is clear. Rather than relying on arbitrary values supplied by the frontend, it limits the resource access scope based on the requester context recognized by the server. In addition, because the request is blocked at the DB lookup stage, it can be terminated before accessing the actual file storage, such as MinIO.

6. Improving the General Image Lookup/Update/Delete Paths

For the general image paths, we modified the repository, store, domain logic, and feature load/flow layers together. The key change was replacing existing findById or findByIdIn lookups with findByIdAndCineroomId and findByIdInAndCineroomId conditions.

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

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

In the lookup logic, we obtained the requester's activeCineroomId and passed it together with imageId. The same standard was applied to update and delete paths. If only lookups were blocked while updates and deletions still used imageId alone, users could still modify or delete resources belonging to another party.

// 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);
}

During this process, we handled the existing system default image fallback path carefully. If context validation were added unconditionally to every path, templates, system default images, and external or shared call paths could be affected. Therefore, we focused the changes on general user image lookup/update/delete paths and distinguished shared template paths according to their actual call intent and impact scope.

7. Defensive Handling of Requests Without activeCineroomId

We also discovered a separate issue during testing. If an image upload was requested while the browser's login or tenant context was in an inconsistent state, X-Tenant-Id was not generated correctly and activeCineroomId could be empty in the server context. The existing code then called Optional.get() directly, causing a NoSuchElementException and ultimately returning a 500 Internal Server Error.

Although this issue was not exactly the same as the H-2 ownership validation issue, it was another defensive handling case discovered during the same work. Instead of returning a confusing 500 error, we added a dedicated exception that returns a clear message indicating that the hospital selection information could not be confirmed.

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();
}

The exception was handled by the proms-image-specific handler. Modifying the common exception-handling logic in common-core could affect other services, so we configured proms-image to catch only the exceptions it needed first.

8. Improving the Medical Questionnaire Image fileId + fileNo Lookup Path

The issue with announcement and hospital registration images was resolved by blocking imageId-based lookups. However, the problem surfaced again when loading medical questionnaires. The medical questionnaire image lookup path searched for existing images based on fileId and fileNo rather than imageId. In this case, even if image_file.cineroom_id was changed to p-1-c-2, the existing row could still be reused as long as fileId + fileNo matched.

To resolve this, we added a cineroomId field to proms-image-client's ClientFindImageByFileIdAndFileNoQuery and changed the server lookup condition to 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
);

This change could not be completed by modifying proms-image alone. Services using proms-image-client also had to pass cineroomId according to the new request format. After checking the actual impact scope, we found that proms-survey and proms-amc-seoul-adapter used this client, so both services required changes.

In proms-survey, we changed the medical questionnaire detail lookup process to pass targetCineroomId as well. In proms-amc-seoul-adapter, we changed the AMIS survey template lookup process to pass the hospital's cineroomId as well.

// 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 Module Deployment and the Impact Scope for Integrated Services

The part requiring the most caution in this modification was deploying proms-image-client. proms-survey and proms-amc-seoul-adapter used the com.proms:proms-image-client library deployed to Nexus. Therefore, changes to the client DTO fields and constructors could also affect compilation in services using that client version.

We did not choose to overwrite the existing version. Because main and develop both referenced the same Nexus, changing the contents of an existing artifact could cause unintended services to receive the new DTO. Therefore, we increased the baseVersion of proms-image-client to 1.0.4, confirmed that the release version was deployed successfully to Nexus, and then explicitly configured proms-survey and proms-amc-seoul-adapter to use that version.

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

Before deployment, we searched for call sites in each repository. If any two-argument calls in the form findImageByFileIdAndFileNo(fileId, fileNo) remained, applying the new client could cause compilation errors or leave the security validation incomplete.

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

Based on the search results, we changed all call sites in proms-survey and proms-amc-seoul-adapter to use three arguments and updated build.gradle to use proms-image-client 1.0.4.

10. Validation Results in develop and stg

After the modifications, we performed sequential validation in the develop and stg environments. The validation covered not only normal lookups but also whether access was blocked after changing the cineroom_id of the test image in the DB to a different hospital's value. Every DB change affected exactly one row based on its id, and we immediately reverted each change after testing.

10.1 Announcement Image Validation

For announcement images, we uploaded an image using a p-1-c-1 user and confirmed that it was displayed normally. We then changed the cineroom_id of the image row to p-1-c-2 and confirmed that the image was not displayed when retrieved by the same user. After reverting the change, the image was displayed normally again.

10.2 Hospital Registration Image Validation

Hospital registration or tenant images were validated in the same manner. After first confirming a normal lookup, we changed image_file.cineroom_id to p-1-c-2. As a result, the image could not be retrieved by a p-1-c-1 user. After changing it back to p-1-c-1, the image was retrieved normally again.

10.3 proms-survey questionnaire image verification

In proms-survey, we first verified on the questionnaire loading screen that the images included in the questionnaire were displayed correctly. After changing the cineroom_id of the image to p-1-c-2, the image was not displayed when viewing the questionnaire as a p-1-c-1 user. After reverting the change, it was displayed correctly again. This confirmed that the cineroomId condition was also applied to the questionnaire image retrieval path.

10.4 proms-amc-seoul-adapter AMIS survey form image verification

Because it was difficult to verify this directly on the screen in proms-amc-seoul-adapter, we called the AMIS survey form retrieval API using Postman. In this path, if an image is missing, the original AMIS file can be fetched again and a new image row can be created. Therefore, we verified not whether “the image is invisible,” but whether “the existing image row changed to p-1-c-2 is reused.”

The test results showed that after changing the cineroom_id of the existing p-1-c-1 image row to p-1-c-2 and retrieving it again with the same fileId + fileNo, the existing row was not reused. Instead, a new image row for p-1-c-1 was created. Since the image from another cineroom was not reused and was processed again based on the requested cineroom, we determined that this behavior was correct. After testing, we cleaned up the newly created row and reverted the existing 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 image verification status

Because access to the screen for Clinical images between medical staff and patients was restricted at the time, we could not perform a test based on the actual screen. However, based on the code, we confirmed that the cineroomId-based retrieval logic was applied in the same way as for notice and questionnaire images. If screen access becomes available in the future, we plan to additionally verify actual uploading, retrieval, and blocking of access from other cinerooms.

11. Lessons learned during implementation

The first lesson was that authentication and authorization must be considered separately. The fact that a user has a valid token means that they are a “logged-in user”; it does not mean that they can access every image resource. This issue was a case where authentication was present, but resource ownership verification was missing.

The second lesson was that the scope of impact of a shared module must always be checked. proms-image-client was a library shared by multiple services. Therefore, changing the client DTO required changes not only to the proms-image server, but also to calling services such as proms-survey and proms-amc-seoul-adapter. Although it appeared to be a modification within a single project, it was actually a change involving integration across multiple services.

The third lesson was that test criteria must be defined differently for each function. For notice and hospital registration images, “the image must not be visible” was the correct verification criterion. In contrast, for AMIS survey form images, a new image could be created through the original-file fallback, so “the existing row from another cineroom must not be reused” was the more accurate criterion.

The fourth lesson was that, when directly manipulating the DB for security verification, the reversion procedure is just as important as the test itself. When testing in develop and stg, we always checked the target row first, changed only one row based on its id, and reverted the change immediately after testing. In cases where a new row was created, we deleted or invalidated the test row to prevent the environment from being contaminated.

Finally, I was reminded that even removing a small log can be something that needs to be checked from a deployment perspective. For changes unrelated directly to functional logic, such as removing System.out.println from ImageIoConfig, a full regression test was not necessary. However, we could verify that the standard output log no longer appeared when a new pod started.

12. Conclusion

This work began as a simple check of whether images were displayed correctly, but it ultimately became a comprehensive review of image resource ownership verification. The issue was initially reproduced with notice images. As we then checked the questionnaire loading and AMIS survey form retrieval paths, we learned that we needed to improve not only imageId-based retrieval, but also the reuse path based on fileId + fileNo.

Ultimately, in proms-image, we removed standalone retrieval using imageId or fileId + fileNo and modified it to include the cineroomId condition. proms-image-client was released as version 1.0.4, and proms-survey and proms-amc-seoul-adapter, which use it, were also modified to pass cineroomId. In the develop and stg environments, we verified normal retrieval and either blocking or non-reuse of images from other cinerooms for notices, hospital registration, questionnaire loading, and AMIS survey form retrieval.

Through this experience, I learned that security issues are difficult to assess by looking at only a single line of code. Even for the same image resource, the access path can differ depending on the screen, API, storage method, and fallback logic. Therefore, a security fix should not address only the “screen where the problem occurred”; it must trace every path that can reach the same resource and establish verification criteria appropriate to each path.

When implementing similar shared resource retrieval functions in the future, I felt that I should develop the habit of checking the resource owner, requester context, retrieval conditions, failure response policy, and the scope of impact on clients from the beginning. This work was an experience in resolving an image security issue and, at the same time, a case in which I learned how to manage shared client changes and verification across multiple services in an MSA environment.

Appendix. Verification checklist

  • Notice images: Verified normal retrieval, blocking when changed to another cineroom, and normal retrieval after reverting the change.

  • Hospital registration images: Verified normal retrieval, blocking when changed to another cineroom, and normal retrieval after reverting the change.

  • proms-survey questionnaire images: Verified that the image was not displayed when cineroom_id was changed and was displayed normally after reverting the change.

  • proms-amc-seoul-adapter AMIS survey form images: Verified that the row from another cineroom was not reused and that a new row was created based on the requested cineroom.

  • proms-image-client: Verified that release version 1.0.4 was deployed to Nexus.

  • proms-survey and proms-amc-seoul-adapter: Verified the use of proms-image-client 1.0.4 and the three-argument call format.

  • Test data changed in the develop and stg DBs was reverted.

wade

Site footer