Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public CustomizeTableResponse getTable(
@PathVariable Long tableId,
@AuthMember Member member
) {
return customizeService.findTable(tableId, member);
return customizeService.findMemberTable(tableId, member);
}

@PutMapping("/api/table/customize/{tableId}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import com.debatetimer.controller.auth.AuthMember;
import com.debatetimer.controller.tool.jwt.AuthManager;
import com.debatetimer.domain.member.Member;
import com.debatetimer.dto.customize.response.CustomizeTableResponse;
import com.debatetimer.dto.sharing.response.ChairmanTokenResponse;
import com.debatetimer.service.customize.CustomizeService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
Expand All @@ -26,4 +29,12 @@ public ChairmanTokenResponse issueChairmanToken(
String chairmanToken = authManager.issueChairmanToken(member, debateTime * 2);
return new ChairmanTokenResponse(chairmanToken);
}

@GetMapping("/api/live/table/customize/{tableId}")
@ResponseStatus(HttpStatus.OK)
public CustomizeTableResponse getTable(
@PathVariable long tableId
) {
return customizeService.findTable(tableId);
}
Comment on lines +33 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

비회원용 토론 테이블 정보를 조회하기 위해 인증 없이 접근 가능한 /api/live/table/customize/{tableId} 엔드포인트를 추가하셨습니다.

하지만 tableId가 순차적인 long 타입(Sequential ID)이기 때문에, 악의적인 사용자가 ID를 1씩 증가시키며 API를 호출(IDOR/BOLA 공격)할 경우 시스템에 등록된 모든 회원의 커스텀 토론 테이블 정보를 무단으로 조회(Scraping)할 수 있는 심각한 보안 취약점이 존재합니다.

권장 해결 방안:

  1. 비순차적 식별자(UUID 등) 사용: 외부 공유용 API에서는 순차적인 DB PK 대신 예측 불가능한 UUID나 별도의 공유용 토큰(Share Token)을 식별자로 사용하도록 설계합니다.
  2. 공유 상태 검증: 테이블 엔티티에 공유 활성화 여부(예: isShared 또는 status 등)를 나타내는 필드를 추가하고, 비회원 조회 시 해당 테이블이 실제로 공유 가능한 상태인지 검증하는 로직을 추가합니다.

Comment on lines +33 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

비회원 조회를 tableId 단독으로 열지 마세요.

Line 33-39는 인증·공유 토큰·라이브 세션 검증 없이 customizeService.findTable(tableId)를 호출합니다. 현재 이 경로는 서비스/도메인 레이어에서 getById(tableId)getCustomizeTimeBoxes(tableId)로 바로 이어지므로, 웹소켓 참가 맥락과 무관하게 tableId만 알면 임의의 사용자 지정 테이블 내용을 조회할 수 있습니다. 비회원 조회가 필요하더라도 공개용 식별자나 세션/공유 토큰 검증으로 범위를 제한하는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/debatetimer/controller/sharing/SharingRestController.java`
around lines 33 - 39, The getTable endpoint in SharingRestController currently
exposes customizeService.findTable(tableId) using only tableId, which allows
unauthorized lookup; update this flow to require a valid access constraint such
as a share token, live-session verification, or another public identifier before
calling findTable. Keep the controller method and customizeService.findTable as
the main locations to adjust, and ensure the lookup is scoped so anonymous
access cannot fetch arbitrary table data.

}
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,27 @@ public CustomizeTable getByIdAndMember(long tableId, Member member) {
}

@Transactional(readOnly = true)
public List<CustomizeTimeBox> getCustomizeTimeBoxes(long tableId, Member member) {
public CustomizeTable getById(long tableId) {
return tableRepository.getById(tableId)
.toDomain();
}

@Transactional(readOnly = true)
public List<CustomizeTimeBox> getMemberCustomizeTimeBoxes(long tableId, Member member) {
CustomizeTableEntity tableEntity = tableRepository.getByIdAndMember(tableId, member);
List<CustomizeTimeBoxEntity> timeBoxEntityList = timeBoxRepository.findAllByCustomizeTable(tableEntity);
List<BellEntity> bellEntityList = bellRepository.findAllByCustomizeTimeBoxIn(timeBoxEntityList);
return toCustomizeTimeBoxes(timeBoxEntityList, bellEntityList);
}

@Transactional(readOnly = true)
public List<CustomizeTimeBox> getCustomizeTimeBoxes(long tableId) {
CustomizeTableEntity tableEntity = tableRepository.getById(tableId);
List<CustomizeTimeBoxEntity> timeBoxEntityList = timeBoxRepository.findAllByCustomizeTable(tableEntity);
List<BellEntity> bellEntityList = bellRepository.findAllByCustomizeTimeBoxIn(timeBoxEntityList);
return toCustomizeTimeBoxes(timeBoxEntityList, bellEntityList);
}
Comment on lines +63 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

두 메서드(getMemberCustomizeTimeBoxesgetCustomizeTimeBoxes)에서 CustomizeTableEntity를 기반으로 CustomizeTimeBox 목록을 조회하고 변환하는 로직이 완전히 중복되고 있습니다. 공통 로직을 private 헬퍼 메서드로 분리하여 코드의 재사용성과 유지보수성을 높이는 것을 권장합니다.

    @Transactional(readOnly = true)
    public List<CustomizeTimeBox> getMemberCustomizeTimeBoxes(long tableId, Member member) {
        CustomizeTableEntity tableEntity = tableRepository.getByIdAndMember(tableId, member);
        return getCustomizeTimeBoxes(tableEntity);
    }

    @Transactional(readOnly = true)
    public List<CustomizeTimeBox> getCustomizeTimeBoxes(long tableId) {
        CustomizeTableEntity tableEntity = tableRepository.getById(tableId);
        return getCustomizeTimeBoxes(tableEntity);
    }

    private List<CustomizeTimeBox> getCustomizeTimeBoxes(CustomizeTableEntity tableEntity) {
        List<CustomizeTimeBoxEntity> timeBoxEntityList = timeBoxRepository.findAllByCustomizeTable(tableEntity);
        List<BellEntity> bellEntityList = bellRepository.findAllByCustomizeTimeBoxIn(timeBoxEntityList);
        return toCustomizeTimeBoxes(timeBoxEntityList, bellEntityList);
    }


@Transactional(readOnly = true)
public long getTotalTimeBoxTimes(long tableId) {
return timeBoxRepository.sumTimeByTableId(tableId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,10 @@ default CustomizeTableEntity getByIdAndMember(long tableId, Member member) {
.orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TABLE_NOT_FOUND));
}

default CustomizeTableEntity getById(long tableId) {
return findById(tableId)
.orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TABLE_NOT_FOUND));
}

void delete(CustomizeTableEntity table);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,16 @@ public CustomizeTableResponse save(CustomizeTableCreateRequest tableCreateReques
}

@Transactional(readOnly = true)
public CustomizeTableResponse findTable(long tableId, Member member) {
public CustomizeTableResponse findMemberTable(long tableId, Member member) {
CustomizeTable table = customizeTableDomainRepository.getByIdAndMember(tableId, member);
List<CustomizeTimeBox> timeBoxes = customizeTableDomainRepository.getCustomizeTimeBoxes(tableId, member);
List<CustomizeTimeBox> timeBoxes = customizeTableDomainRepository.getMemberCustomizeTimeBoxes(tableId, member);
return new CustomizeTableResponse(table, timeBoxes);
}

@Transactional(readOnly = true)
public CustomizeTableResponse findTable(long tableId) {
CustomizeTable table = customizeTableDomainRepository.getById(tableId);
List<CustomizeTimeBox> timeBoxes = customizeTableDomainRepository.getCustomizeTimeBoxes(tableId);
return new CustomizeTableResponse(table, timeBoxes);
}

Expand All @@ -54,7 +61,7 @@ public CustomizeTableResponse updateTable(
@Transactional
public CustomizeTableResponse updateUsedAt(long tableId, Member member) {
CustomizeTable table = customizeTableDomainRepository.updateUsedAt(tableId, member);
List<CustomizeTimeBox> timeBoxes = customizeTableDomainRepository.getCustomizeTimeBoxes(tableId, member);
List<CustomizeTimeBox> timeBoxes = customizeTableDomainRepository.getMemberCustomizeTimeBoxes(tableId, member);
return new CustomizeTableResponse(table, timeBoxes);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ class GetTable {
null, null, 360, null, null)
)
);
doReturn(response).when(customizeService).findTable(eq(tableId), any());
doReturn(response).when(customizeService).findMemberTable(eq(tableId), any());

var document = document("customize/get", 200)
.request(requestDocument)
Expand All @@ -282,7 +282,7 @@ class GetTable {
@EnumSource(value = ClientErrorCode.class, names = {"TABLE_NOT_FOUND"})
void 사용자_지정_테이블_조회_실패(ClientErrorCode errorCode) {
long tableId = 5L;
doThrow(new DTClientErrorException(errorCode)).when(customizeService).findTable(eq(tableId), any());
doThrow(new DTClientErrorException(errorCode)).when(customizeService).findMemberTable(eq(tableId), any());

var document = document("customize/get", errorCode)
.request(requestDocument)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.payload.JsonFieldType.ARRAY;
import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN;
import static org.springframework.restdocs.payload.JsonFieldType.NUMBER;
import static org.springframework.restdocs.payload.JsonFieldType.OBJECT;
import static org.springframework.restdocs.payload.JsonFieldType.STRING;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
Expand All @@ -12,10 +17,23 @@
import com.debatetimer.controller.RestDocumentationRequest;
import com.debatetimer.controller.RestDocumentationResponse;
import com.debatetimer.controller.Tag;
import com.debatetimer.domain.customize.BellType;
import com.debatetimer.domain.customize.CustomizeBoxType;
import com.debatetimer.domain.customize.Stance;
import com.debatetimer.domain.member.Member;
import com.debatetimer.dto.customize.response.BellResponse;
import com.debatetimer.dto.customize.response.CustomizeTableInfoResponse;
import com.debatetimer.dto.customize.response.CustomizeTableResponse;
import com.debatetimer.dto.customize.response.CustomizeTimeBoxResponse;
import com.debatetimer.dto.member.TableType;
import com.debatetimer.exception.custom.DTClientErrorException;
import com.debatetimer.exception.errorcode.ClientErrorCode;
import io.restassured.http.ContentType;
import java.util.List;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.http.HttpHeaders;

public class SharingDocumentTest extends BaseDocumentTest {
Expand Down Expand Up @@ -56,4 +74,98 @@ class IssueChairmanToken {
.then().statusCode(200);
}
}

@Nested
class GetTable {

private final RestDocumentationRequest requestDocument = request()
.tag(Tag.SHARING_API)
.summary("비회원용 사용자 지정 토론 시간표 조회")
.description("""
### 타임 박스 종류에 따른 웅답 값
| 타임 박스 종류 | 필수 입력 | 선택 입력 | null 입력 |
| :---: | ---| --- | --- |
| 커스텀 타임 박스 | stance, speechType, boxType, time | speaker | timePerTeam, timePerSpeaking |
| 자유 토론 타임 박스 | stance, speechType, boxType, timePerTeam | speaker, timePerSpeaking | time |
""")
.pathParameter(
parameterWithName("tableId").description("테이블 ID")
);

private final RestDocumentationResponse responseDocument = response()
.responseBodyField(
fieldWithPath("id").type(NUMBER).description("테이블 ID"),
fieldWithPath("info").type(OBJECT).description("토론 테이블 정보"),
fieldWithPath("info.name").type(STRING).description("테이블 이름"),
fieldWithPath("info.agenda").type(STRING).description("토론 주제"),
fieldWithPath("info.type").type(STRING).description("토론 테이블 유형"),
fieldWithPath("info.prosTeamName").type(STRING).description("찬성팀 팀명"),
fieldWithPath("info.consTeamName").type(STRING).description("반대팀 팀명"),
fieldWithPath("info.warningBell").type(BOOLEAN).description("30초 종소리 유무"),
fieldWithPath("info.finishBell").type(BOOLEAN).description("발언 종료 종소리 유무"),
fieldWithPath("table").type(ARRAY).description("토론 테이블 구성"),
fieldWithPath("table[].stance").type(STRING).description("입장"),
fieldWithPath("table[].speechType").type(STRING).description("발언 유형"),
fieldWithPath("table[].boxType").type(STRING).description("타임 박스 유형"),
fieldWithPath("table[].time").type(NUMBER).description("발언 시간(초)").optional(),
fieldWithPath("table[].bell").type(ARRAY).description("종소리 정보").optional(),
fieldWithPath("table[].bell[].type").type(STRING).description("종소리 종류"),
fieldWithPath("table[].bell[].time").type(NUMBER).description("종소리 울릴 시간(초)"),
fieldWithPath("table[].bell[].count").type(NUMBER).description("종소리 횟수"),
fieldWithPath("table[].timePerTeam").type(NUMBER).description("팀당 발언 시간 (초)").optional(),
fieldWithPath("table[].timePerSpeaking").type(NUMBER).description("1회 발언 시간 (초)").optional(),
fieldWithPath("table[].speaker").type(STRING).description("발언자 이름").optional()
);

@Test
void 비회원_사용자_지정_테이블_조회_성공() {
long tableId = 5L;
CustomizeTableResponse response = new CustomizeTableResponse(
5L,
new CustomizeTableInfoResponse("나의 테이블", TableType.CUSTOMIZE, "토론 주제",
"찬성", "반대", true, true),
List.of(
new CustomizeTimeBoxResponse(Stance.PROS, "입론", CustomizeBoxType.NORMAL,
120, List.of(new BellResponse(BellType.AFTER_START, 90, 1)), null, null, "콜리"),
new CustomizeTimeBoxResponse(Stance.CONS, "입론", CustomizeBoxType.NORMAL,
120, List.of(new BellResponse(BellType.AFTER_START, 90, 1),
new BellResponse(BellType.AFTER_START, 120, 2)), null, null, "비토"),
new CustomizeTimeBoxResponse(Stance.NEUTRAL, "난상 토론", CustomizeBoxType.TIME_BASED,
null, null, 360, 120, null),
new CustomizeTimeBoxResponse(Stance.NEUTRAL, "존중 토론", CustomizeBoxType.TIME_BASED,
null, null, 360, null, null)
)
);
doReturn(response).when(customizeService).findTable(eq(tableId));

var document = document("sharing/get_table", 200)
.request(requestDocument)
.response(responseDocument)
.build();

given(document)
.contentType(ContentType.JSON)
.pathParam("tableId", tableId)
.when().get("/api/live/table/customize/{tableId}")
.then().statusCode(200);
}

@ParameterizedTest
@EnumSource(value = ClientErrorCode.class, names = {"TABLE_NOT_FOUND"})
void 비회원_사용자_지정_테이블_조회_실패(ClientErrorCode errorCode) {
long tableId = 5L;
doThrow(new DTClientErrorException(errorCode)).when(customizeService).findTable(eq(tableId));

var document = document("sharing/get_table", errorCode)
.request(requestDocument)
.response(ERROR_RESPONSE)
.build();

given(document)
.contentType(ContentType.JSON)
.pathParam("tableId", tableId)
.when().get("/api/live/table/customize/{tableId}")
.then().statusCode(errorCode.getStatus().value());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.debatetimer.controller.sharing;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;

import com.debatetimer.controller.BaseControllerTest;
import com.debatetimer.domain.customize.CustomizeBoxType;
import com.debatetimer.domain.member.Member;
import com.debatetimer.dto.customize.response.CustomizeTableResponse;
import com.debatetimer.entity.customize.CustomizeTableEntity;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

class SharingRestControllerTest extends BaseControllerTest {

@Nested
class GetTable {

@Test
void 비회원이_사용자_지정_테이블을_조회한다() {
Member bito = memberGenerator.generate("default@gmail.com");
CustomizeTableEntity bitoTable = customizeTableEntityGenerator.generate(bito);
customizeTimeBoxEntityGenerator.generate(bitoTable, CustomizeBoxType.NORMAL, 1);
customizeTimeBoxEntityGenerator.generateNotExistSpeaker(bitoTable, CustomizeBoxType.NORMAL, 2);

CustomizeTableResponse response = given()
.contentType(ContentType.JSON)
.pathParam("tableId", bitoTable.getId())
.when().get("/api/live/table/customize/{tableId}")
.then().statusCode(200)
.extract().as(CustomizeTableResponse.class);

assertAll(
() -> assertThat(response.id()).isEqualTo(bitoTable.getId()),
() -> assertThat(response.table()).hasSize(2)
);
}

@Test
void 존재하지_않는_테이블을_조회하면_예외가_발생한다() {
long notExistTableId = 999L;

given()
.contentType(ContentType.JSON)
.pathParam("tableId", notExistTableId)
.when().get("/api/live/table/customize/{tableId}")
.then().statusCode(404);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class GetCustomizeTimeBoxes {
timeBoxEntityGenerator.generate(tableEntity, CustomizeBoxType.NORMAL, 2, 180);
timeBoxEntityGenerator.generate(tableEntity, CustomizeBoxType.NORMAL, 3, 120);

List<CustomizeTimeBox> timeBoxes = customizeTableDomainRepository.getCustomizeTimeBoxes(
List<CustomizeTimeBox> timeBoxes = customizeTableDomainRepository.getMemberCustomizeTimeBoxes(
tableEntity.getId(), member);

assertThat(timeBoxes).hasSize(3)
Expand All @@ -95,7 +95,7 @@ class GetCustomizeTimeBoxes {
bellEntityGenerator.generate(timeBoxEntity1, BellType.BEFORE_END, 30, 1);
bellEntityGenerator.generate(timeBoxEntity2, BellType.BEFORE_END, 10, 1);

List<CustomizeTimeBox> timeBoxes = customizeTableDomainRepository.getCustomizeTimeBoxes(
List<CustomizeTimeBox> timeBoxes = customizeTableDomainRepository.getMemberCustomizeTimeBoxes(
tableEntity.getId(), member);

assertAll(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class FindTable {
bellEntityGenerator.generate(customizeTimeBox, BellType.AFTER_START, 1, 1);
bellEntityGenerator.generate(customizeTimeBox, BellType.AFTER_START, 1, 2);

CustomizeTableResponse foundResponse = customizeService.findTable(chanTable.getId(), chan);
CustomizeTableResponse foundResponse = customizeService.findMemberTable(chanTable.getId(), chan);

assertAll(
() -> assertThat(foundResponse.id()).isEqualTo(chanTable.getId()),
Expand All @@ -93,7 +93,40 @@ class FindTable {
CustomizeTableEntity chanTable = customizeTableEntityGenerator.generate(chan);
long chanTableId = chanTable.getId();

assertThatThrownBy(() -> customizeService.findTable(chanTableId, coli))
assertThatThrownBy(() -> customizeService.findMemberTable(chanTableId, coli))
.isInstanceOf(DTClientErrorException.class)
.hasMessage(ClientErrorCode.TABLE_NOT_FOUND.getMessage());
}
}

@Nested
class FindTableForGuest {

@Test
void 비회원이_사용자_지정_토론_테이블을_조회한다() {
Member chan = memberGenerator.generate("default@gmail.com");
CustomizeTableEntity chanTable = customizeTableEntityGenerator.generate(chan);
CustomizeTimeBoxEntity customizeTimeBox = customizeTimeBoxEntityGenerator.generate(
chanTable, CustomizeBoxType.NORMAL, 1);
customizeTimeBoxEntityGenerator.generate(chanTable, CustomizeBoxType.NORMAL, 2);
bellEntityGenerator.generate(customizeTimeBox, BellType.AFTER_START, 1, 1);
bellEntityGenerator.generate(customizeTimeBox, BellType.AFTER_START, 1, 2);

CustomizeTableResponse foundResponse = customizeService.findTable(chanTable.getId());

assertAll(
() -> assertThat(foundResponse.id()).isEqualTo(chanTable.getId()),
() -> assertThat(foundResponse.table()).hasSize(2),
() -> assertThat(foundResponse.table().get(0).bell()).hasSize(2),
() -> assertThat(foundResponse.table().get(1).bell()).isNull()
);
}

@Test
void 존재하지_않는_테이블_조회_시_예외를_발생시킨다() {
long notExistTableId = 999L;

assertThatThrownBy(() -> customizeService.findTable(notExistTableId))
.isInstanceOf(DTClientErrorException.class)
.hasMessage(ClientErrorCode.TABLE_NOT_FOUND.getMessage());
}
Expand Down
Loading