—От выявления проблемы поиска только по 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);
}
В этой структуре, хотя cineroom_id хранился в таблице image_file базы данных, он не входил в фактическое условие поиска. Иными словами, даже если изображение было сохранено как принадлежащее p-1-c-2, пользователь из p-1-c-1 мог получить его, отправив запрос с соответствующим imageId.
С точки зрения безопасности это была не просто ошибка поиска, а отсутствие проверки принадлежности ресурса. Даже аутентифицированные пользователи не получают автоматически право доступа ко всем ресурсам. В сервисах, где данные разделены по больницам или организациям, область принадлежности ресурса также должна проверяться всегда.
3. Как была обнаружена проблема
Для расследования проблемы мы сначала загрузили изображение объявления в окружении develop, используя пользователя p-1-c-1. После загрузки мы подтвердили в базе данных, что изображение было сохранено в таблице image_file, а cineroom_id имел значение p-1-c-1.
Затем в рамках проверки безопасности мы изменили только cineroom_id соответствующей строки изображения на p-1-c-2. В таком состоянии мы снова получили то же объявление, используя того же пользователя p-1-c-1. Ожидаемым поведением было скрытие изображения или блокировка с ответом 404. Однако в существующем окружении develop изображение по-прежнему отображалось как раньше.
Чтобы исключить влияние кэша браузера, мы выполнили принудительное обновление страницы и подтвердили на вкладке Network, что запрос поиска изображения возвращал 200 OK. В заголовке запроса X-Tenant-Id имел значение p-1-c-1, тогда как 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. Анализ первопричины: путей поиска было несколько
Сначала могло показаться, что достаточно изменить только API поиска изображений объявлений. Однако proms-image использовался несколькими функциями как общий компонент, и существовало более одного метода поиска изображений. Основными методами были поиск по imageId, поиск по списку imageIds и поиск по fileId + fileNo.
Пути, напрямую использовавшие imageId, например для изображений объявлений и изображений регистрации больниц, было относительно легко отследить. В отличие от них, изображения медицинских анкет и шаблонов опросов AMIS использовали пути, повторно применявшие существующие изображения на основе fileId и fileNo. Этот путь было легко не включить в первоначальный объём изменений. Фактически во время тестов загрузки медицинской анкеты мы обнаружили, что изображение продолжало извлекаться даже после изменения cineroom_id, что подтвердило необходимость дополнительных исправлений.
Анализ первопричины выявил две основные области, требующие изменений.
-
В обычных путях поиска, обновления и удаления изображений необходимо было убрать поиск только по imageId и заменить его поиском по imageId + cineroomId.
-
В пути поиска метаданных изображений медицинских анкет необходимо было убрать поиск только по fileId + fileNo и заменить его поиском по cineroomId + fileId + fileNo.
-
proms-survey и proms-amc-seoul-adapter, использующие proms-image-client, также должны были передавать cineroomId в соответствии с новым форматом запроса.
5. Направление изменений: проверка cineroomId запрашивающего пользователя вместе с cineroomId ресурса
Направление изменений было простым. При получении ресурса изображения мы должны были всегда включать cineroomId запрашивающего пользователя в условие поиска вместо поиска только по imageId или fileId + fileNo. cineroomId запрашивающего пользователя получался из пользовательского контекста, управляемого сервером.
После изменения условия поиска изображения, принадлежащие другому cineroom, больше не извлекались из базы данных. Поскольку различие между «доступ запрещён» и «не найдено» могло раскрыть факт существования ресурса, мы решили обрабатывать доступ к изображениям другого cineroom как 404 Not Found — так же, как обращение к несуществующему изображению.
// 수정 방향 예시
String cineroomId = requesterCineroomId();
ImageFile imageFile = imageFileLogic.findImageFile(imageId, cineroomId);
if (imageFile == null) {
throw new ImageResourceNotFoundException();
}
Преимущество такого подхода заключается в ясности ответственности за проверку. Вместо зависимости от произвольных значений, передаваемых frontend, область доступа к ресурсам ограничивается контекстом запрашивающего пользователя, распознанным сервером. Кроме того, поскольку запрос блокируется на этапе поиска в базе данных, его можно завершить до обращения к фактическому хранилищу файлов, например MinIO.
6. Улучшение обычных путей поиска, обновления и удаления изображений
Для обычных путей работы с изображениями мы одновременно изменили repository, store, доменную логику и слои загрузки/потока функций. Ключевым изменением стала замена существующих поисков 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);
}
В ходе этого процесса мы внимательно обработали существующий путь резервного использования системного изображения по умолчанию. Если безусловно добавить проверку контекста во все пути, это могло бы повлиять на шаблоны, системные изображения по умолчанию, а также внешние или общие пути вызова. Поэтому мы сосредоточили изменения на обычных путях поиска, обновления и удаления пользовательских изображений, а общие пути шаблонов разделили с учётом фактического назначения вызова и области его влияния.
7. Защитная обработка запросов без activeCineroomId
Во время тестирования мы также обнаружили отдельную проблему. Если запрос на загрузку изображения отправлялся в момент, когда состояние входа в браузере или контекст tenant находились в несогласованном состоянии, X-Tenant-Id формировался некорректно, а 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. Изменение общей логики обработки исключений в common-core могло повлиять на другие сервисы, поэтому сначала мы настроили proms-image на перехват только необходимых ему исключений.
8. Улучшение пути поиска изображений медицинских анкет по fileId + fileNo
Проблема с изображениями объявлений и регистрации больниц была решена блокировкой поиска по imageId. Однако при загрузке медицинских анкет проблема возникла снова. Путь поиска изображений медицинских анкет находил существующие изображения на основе fileId и fileNo, а не imageId. В этом случае, даже если image_file.cineroom_id изменить на p-1-c-2, существующая строка всё ещё могла быть повторно использована, пока совпадали fileId + fileNo.
Чтобы решить эту проблему, мы добавили поле cineroomId в ClientFindImageByFileIdAndFileNoQuery клиента proms-image-client и изменили условие поиска на сервере на 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, поэтому потребовались изменения в обоих сервисах.
В 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. Развёртывание клиентского модуля и область влияния на интегрированные сервисы
Наиболее осторожного подхода в этих изменениях требовало развёртывание proms-image-client. proms-survey и proms-amc-seoul-adapter использовали библиотеку com.proms:proms-image-client, развёрнутую в Nexus. Поэтому изменения полей DTO и конструкторов клиента могли повлиять и на компиляцию сервисов, использующих эту версию клиента.
Мы не стали перезаписывать существующую версию. Поскольку и main, и develop ссылались на один и тот же Nexus, изменение содержимого существующего артефакта могло привести к тому, что непредусмотренные сервисы получили бы новый DTO. Поэтому мы увеличили baseVersion proms-image-client до 1.0.4, подтвердили успешное развёртывание релизной версии в Nexus, а затем явно настроили proms-survey и proms-amc-seoul-adapter на использование этой версии.
// 연동 서비스 build.gradle 예시
implementation 'com.proms:proms-image-client:1.0.4'
Перед развёртыванием мы выполнили поиск мест вызова в каждом репозитории. Если бы сохранились вызовы с двумя аргументами в форме findImageByFileIdAndFileNo(fileId, fileNo), применение нового клиента могло бы привести к ошибкам компиляции или оставить проверку безопасности незавершённой.
rg "findImageByFileIdAndFileNo\(" -n .
rg "new ClientFindImageByFileIdAndFileNoQuery" -n .
rg "proms-image-client" -n .
На основании результатов поиска мы изменили все места вызова в proms-survey и proms-amc-seoul-adapter, чтобы они использовали три аргумента, и обновили build.gradle для использования proms-image-client 1.0.4.
10. Результаты проверки в develop и stg
После внесения изменений мы последовательно провели проверку в окружениях develop и stg. Проверка охватывала не только обычный поиск, но и блокировку доступа после изменения cineroom_id тестового изображения в базе данных на значение другой больницы. Каждое изменение в базе данных затрагивало ровно одну строку по её id, и после тестирования мы сразу возвращали исходное значение.
10.1 Проверка изображения объявления
Для изображений объявлений мы загрузили изображение, используя пользователя p-1-c-1, и подтвердили его нормальное отображение. Затем мы изменили 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 Проверка изображения формы опроса AMIS в proms-amc-seoul-adapter
Поскольку в proms-amc-seoul-adapter было сложно проверить это непосредственно на экране, мы вызвали API получения формы опроса AMIS с помощью Postman. В этом пути, если изображение отсутствует, исходный файл AMIS можно получить повторно и создать новую строку изображения. Поэтому мы проверяли не то, «невидимо ли изображение», а то, «повторно ли используется существующая строка изображения, изменённая на p-1-c-2».
Результаты тестирования показали, что после изменения cineroom_id существующей строки изображения p-1-c-1 на p-1-c-2 и повторного получения изображения с теми же fileId + fileNo существующая строка повторно не использовалась. Вместо этого была создана новая строка изображения для p-1-c-1. Поскольку изображение из другого cineroom не использовалось повторно и обрабатывалось заново на основе запрошенного cineroom, мы определили, что такое поведение является корректным. После тестирования мы удалили вновь созданную строку и отменили изменение существующей строки.
-- 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 Статус проверки клинических изображений
Поскольку в тот момент доступ к экрану клинических изображений между медицинскими работниками и пациентами был ограничен, мы не смогли выполнить тест на основе фактического экрана. Однако на основе кода мы подтвердили, что логика получения на основе cineroomId применяется так же, как для изображений уведомлений и анкет. Если в будущем доступ к экрану станет возможен, мы дополнительно проверим фактическую загрузку, получение и блокировку доступа из других cineroom.
11. Извлечённые уроки во время реализации
Первый урок заключался в том, что аутентификацию и авторизацию необходимо рассматривать отдельно. Наличие у пользователя действительного токена означает, что он является «вошедшим в систему пользователем», но не означает, что ему доступен каждый ресурс изображения. В данном случае аутентификация присутствовала, но проверка принадлежности ресурса отсутствовала.
Второй урок заключался в том, что необходимо всегда проверять область влияния общего модуля. proms-image-client являлся библиотекой, общей для нескольких сервисов. Поэтому изменение DTO клиента потребовало изменений не только в сервере proms-image, но и в вызывающих сервисах, таких как proms-survey и proms-amc-seoul-adapter. Хотя это выглядело как изменение внутри одного проекта, фактически это было изменение, затрагивающее интеграцию нескольких сервисов.
Третий урок заключался в том, что критерии тестирования необходимо определять отдельно для каждой функции. Для изображений уведомлений и регистрации в больнице правильным критерием проверки было «изображение не должно отображаться». Напротив, для изображений форм опроса AMIS через резервный сценарий с исходным файлом могло быть создано новое изображение, поэтому более точным критерием было «существующая строка из другого cineroom не должна использоваться повторно».
Четвёртый урок заключался в том, что при непосредственном изменении БД для проверки безопасности процедура отмены изменений так же важна, как и сам тест. При тестировании в develop и stg мы всегда сначала проверяли целевую строку, изменяли только одну строку по её id и сразу после тестирования отменяли изменение. Если создавалась новая строка, мы удаляли или делали недействительной тестовую строку, чтобы предотвратить загрязнение среды.
Наконец, я ещё раз убедился, что даже удаление небольшой записи в лог может потребовать проверки с точки зрения развёртывания. Для изменений, напрямую не связанных с функциональной логикой, таких как удаление System.out.println из ImageIoConfig, полное регрессионное тестирование не требовалось. Однако мы могли проверить, что при запуске нового 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 мы проверили корректное получение, а также блокировку или отказ от повторного использования изображений из других cineroom для уведомлений, регистрации в больнице, загрузки анкет и получения форм опроса AMIS.
Благодаря этому опыту я понял, что оценить проблемы безопасности сложно, рассматривая только одну строку кода. Даже для одного и того же ресурса изображения путь доступа может отличаться в зависимости от экрана, API, способа хранения и резервной логики. Поэтому исправление проблемы безопасности не должно ограничиваться только «экраном, на котором возникла проблема»: необходимо проследить каждый путь, по которому можно получить доступ к тому же ресурсу, и установить критерии проверки, соответствующие каждому пути.
В будущем при реализации аналогичных функций получения общих ресурсов я хочу с самого начала выработать привычку проверять владельца ресурса, контекст запрашивающей стороны, условия получения, политику ответа при ошибке и область влияния на клиентов. Эта работа стала опытом устранения проблемы безопасности изображений и одновременно примером того, как управлять изменениями общего клиента и проверками в нескольких сервисах в среде MSA.
Приложение. Контрольный список проверок
-
Изображения уведомлений: проверены корректное получение, блокировка после изменения на другой cineroom и корректное получение после отмены изменения.
-
Изображения регистрации в больнице: проверены корректное получение, блокировка после изменения на другой cineroom и корректное получение после отмены изменения.
-
Изображения анкет proms-survey: проверено, что изображение не отображалось после изменения cineroom_id и отображалось нормально после отмены изменения.
-
Изображения форм опроса AMIS в proms-amc-seoul-adapter: проверено, что строка из другого cineroom не использовалась повторно и что новая строка была создана на основе запрошенного cineroom.
-
proms-image-client: проверено, что версия выпуска 1.0.4 была развёрнута в Nexus.
-
proms-survey и proms-amc-seoul-adapter: проверено использование proms-image-client 1.0.4 и формата вызова с тремя аргументами.
-
Изменения тестовых данных в БД develop и stg были отменены.
wade