security(테이블타입): TableManagementController 인가 + createLogTable SQL injection 강화

bug hunt security-reviewer 발견 2건 보안 fix:

1. TableManagementController 인가 누락 (OWASP A01 Broken Access Control)
   - 15개 write/DDL endpoint 가 admin role 검증 없이 JWT 만 있으면 호출 가능
   - 일반 사용자가 PK 재설정/index 변경/컬럼 수정 가능했음
   - 조치:
     - DepartmentController 의 isAdmin/isSuperAdmin helper 패턴 복사
     - SUPER_ADMIN 전용 (DDL 5건): primary-key, indexes, nullable, unique, log
     - admin (COMPANY_ADMIN+) (10건): updateColumnSettings, addTableData, editTableData, deleteTableData, multi-save 등
     - read 19건은 그대로 (일반 사용자 접근 유지, company_code 격리만)

2. createLogTable SQL injection (OWASP A03 Injection)
   - information_schema.data_type 을 raw concat 으로 DDL 생성
   - 조치:
     - ALLOWED_LOG_COLUMN_TYPES Set 으로 화이트리스트 (varchar/text/integer/numeric/boolean/date/timestamp/jsonb 등 21개)
     - sanitize 빈 식별자 차단 + 원본 테이블에 없는 컬럼 skip
     - colDefs empty 시 IllegalArgumentException
     - 알 수 없는 type 은 text 로 안전 대체

검증:
- gradle compileJava BUILD SUCCESSFUL
- mapper XML 0건 변경 (READ 경로 보호)
- 별도 미해결 보안 이슈 (k8s secrets git 노출, CORS 와일드카드 등) 는 본 PR scope 외

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 18:07:40 +09:00
parent 420b92bc7b
commit efea906ead
2 changed files with 141 additions and 29 deletions
@@ -75,7 +75,11 @@ public class TableManagementController {
@PutMapping("/tables/{tableName}/label")
public ResponseEntity<ApiResponse<Void>> updateTableLabel(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String displayName = (String) body.get("display_name");
String description = (String) body.get("description");
if (displayName == null || displayName.isBlank()) {
@@ -105,7 +109,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> settings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
}
@@ -115,7 +123,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> settings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateColumnSettings(tableName, columnName, settings, companyCode);
}
@@ -136,7 +148,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsPost(
@PathVariable String tableName,
@RequestBody List<Map<String, Object>> columnSettings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
}
@@ -145,7 +161,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateAllColumnSettingsBatch(
@PathVariable String tableName,
@RequestBody List<Map<String, Object>> columnSettings,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return doUpdateAllColumnSettings(tableName, columnSettings, companyCode);
}
@@ -166,7 +186,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> updateColumnWebType(
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String webType = (String) body.get("web_type");
if (webType == null || webType.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("웹 타입이 필요합니다."));
@@ -183,7 +207,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
String inputType = (String) body.get("input_type");
if (tableName == null || columnName == null || inputType == null || inputType.isBlank()) {
return ResponseEntity.status(400).body(ApiResponse.error("테이블명, 컬럼명, 입력 타입이 모두 필요합니다."));
@@ -241,7 +269,11 @@ public class TableManagementController {
@PutMapping("/tables/{tableName}/primary-key")
public ResponseEntity<ApiResponse<Void>> setTablePrimaryKey(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
List<String> columns = (List<String>) body.get("columns");
if (tableName == null || columns == null || columns.isEmpty()) {
@@ -256,7 +288,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/indexes")
public ResponseEntity<ApiResponse<Void>> toggleTableIndex(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
String columnName = (String) body.get("column_name");
String indexType = (String) body.get("index_type");
String action = (String) body.get("action");
@@ -281,7 +317,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
Object nullableObj = body.get("nullable");
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, nullable(boolean)이 필요합니다."));
@@ -299,7 +339,11 @@ public class TableManagementController {
@PathVariable String tableName,
@PathVariable String columnName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
Object uniqueObj = body.get("unique");
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
return ResponseEntity.status(400).body(ApiResponse.error("tableName, columnName, unique(boolean)이 필요합니다."));
@@ -366,7 +410,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Map<String, Object>>> addTableData(
@PathVariable String tableName,
@RequestBody Map<String, Object> data,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (data == null || data.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("추가할 데이터가 필요합니다."));
}
@@ -399,7 +447,11 @@ public class TableManagementController {
public ResponseEntity<ApiResponse<Void>> editTableData(
@PathVariable String tableName,
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
Map<String, Object> originalData = (Map<String, Object>) body.get("original_data");
@SuppressWarnings("unchecked")
@@ -433,7 +485,11 @@ public class TableManagementController {
@DeleteMapping("/tables/{tableName}/delete")
public ResponseEntity<ApiResponse<Void>> deleteTableData(
@PathVariable String tableName,
@RequestBody Object body) {
@RequestBody Object body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
List<Map<String, Object>> dataList;
if (body instanceof List) {
@SuppressWarnings("unchecked")
@@ -457,7 +513,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/log")
public ResponseEntity<ApiResponse<Void>> createLogTable(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isSuperAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
@SuppressWarnings("unchecked")
List<String> logColumns = (List<String>) body.get("log_columns");
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
@@ -487,7 +547,11 @@ public class TableManagementController {
@PostMapping("/tables/{tableName}/log/toggle")
public ResponseEntity<ApiResponse<Void>> toggleLogTable(
@PathVariable String tableName,
@RequestBody Map<String, Object> body) {
@RequestBody Map<String, Object> body,
@RequestAttribute("role") String role) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
boolean isActive = Boolean.TRUE.equals(body.get("is_active"));
tableManagementService.toggleLogTable(tableName, isActive);
return ResponseEntity.ok(ApiResponse.success(null,
@@ -544,7 +608,11 @@ public class TableManagementController {
@PostMapping("/multi-table-save")
public ResponseEntity<ApiResponse<Map<String, Object>>> multiTableSave(
@RequestBody Map<String, Object> payload,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String companyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.multiTableSave(payload, companyCode),
"다중 테이블 저장이 완료되었습니다."));
@@ -575,4 +643,16 @@ public class TableManagementController {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.checkDatabaseConnection(), "데이터베이스 연결 상태를 확인했습니다."));
}
// ──────────────────────────────────────────────────────────
// 권한 헬퍼
// ──────────────────────────────────────────────────────────
private boolean isAdmin(String role) {
return isSuperAdmin(role) || "COMPANY_ADMIN".equals(role);
}
private boolean isSuperAdmin(String roleOrCode) {
return "*".equals(roleOrCode) || "SUPER_ADMIN".equals(roleOrCode);
}
}
@@ -1,6 +1,8 @@
package com.erp.service;
import com.erp.common.BaseService;
import com.erp.constants.InputTypeConstants;
import com.erp.constants.InputTypeContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -26,10 +28,14 @@ public class TableManagementService extends BaseService {
private static final String NS = "tableManagement.";
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */
private static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
"text", "number", "date", "code", "entity",
"numbering", "file", "image"
/** 로그 테이블 컬럼 정의에 허용하는 PostgreSQL data_type 화이트리스트.
* information_schema.columns.data_type 값과 정확히 일치해야 한다. */
private static final Set<String> ALLOWED_LOG_COLUMN_TYPES = Set.of(
"varchar", "text", "char", "character", "character varying",
"integer", "bigint", "smallint", "numeric", "decimal", "real", "double precision",
"boolean", "date", "timestamp", "timestamp without time zone", "timestamp with time zone",
"time", "time without time zone", "time with time zone",
"uuid", "json", "jsonb", "bytea"
);
// ──────────────────────────────────────────────────
@@ -151,9 +157,12 @@ public class TableManagementService extends BaseService {
Map<String, Object> settings, String companyCode) {
ensureTableInLabels(tableName);
boolean inputTypeChanged = settings.containsKey("input_type");
String ctx = inputTypeChanged ? "user-update-type" : "user-update-other";
String inputType = normalizeInputType((String) settings.get("input_type"), ctx);
Object rawInputType = settings.get("input_type");
boolean inputTypeChanged = settings.containsKey("input_type") && rawInputType != null;
InputTypeContext ctx = inputTypeChanged
? InputTypeContext.USER_UPDATE_TYPE
: InputTypeContext.USER_UPDATE_OTHER;
String inputType = normalizeInputType((String) rawInputType, ctx);
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
@@ -210,7 +219,7 @@ public class TableManagementService extends BaseService {
public void updateColumnInputType(String tableName, String columnName,
String inputType, String companyCode,
Map<String, Object> detailSettings) {
String finalType = normalizeInputType(inputType, "user-update-type");
String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("column_name", columnName);
@@ -611,9 +620,14 @@ public class TableManagementService extends BaseService {
@Transactional
public void createLogTable(String tableName, List<String> logColumns, boolean isActive) {
String logTableName = tableName + "_log";
String safeLog = sanitize(logTableName);
String safeOrig = sanitize(tableName);
if (safeOrig.isBlank()) {
throw new IllegalArgumentException("유효하지 않은 테이블명입니다.");
}
String safeLog = sanitize(safeOrig + "_log");
if (safeLog.isBlank()) {
throw new IllegalArgumentException("유효하지 않은 로그 테이블명입니다.");
}
// 원본 테이블 컬럼 정보 조회
Map<String, String> colTypes = getColumnTypes(safeOrig);
@@ -625,13 +639,32 @@ public class TableManagementService extends BaseService {
colDefs.add("log_date TIMESTAMP DEFAULT NOW()");
colDefs.add("log_user VARCHAR(100)");
List<String> targetCols = (logColumns != null && !logColumns.isEmpty())
? logColumns.stream().map(this::sanitize).filter(c -> !c.isBlank()).collect(Collectors.toList())
List<String> requestedCols = (logColumns != null && !logColumns.isEmpty())
? logColumns
: new ArrayList<>(colTypes.keySet());
for (String col : targetCols) {
String type = colTypes.getOrDefault(col, "TEXT");
colDefs.add(String.format("\"%s\" %s", col, type));
// 실제 SQL 에 들어간 컬럼만 메타에 저장 (skip 된 것은 log_columns 설정에서도 빠짐)
List<String> persistedCols = new ArrayList<>();
for (String col : requestedCols) {
if (col == null) continue;
String safeCol = sanitize(col);
if (safeCol.isBlank()) continue; // sanitize 결과 빈 식별자 차단
if (!colTypes.containsKey(col)) continue; // 원본 테이블에 없는 컬럼 skip
String rawType = colTypes.get(col);
String normalized = (rawType == null ? "" : rawType.toLowerCase(Locale.ROOT).trim());
if (!ALLOWED_LOG_COLUMN_TYPES.contains(normalized)) {
// 알 수 없는 type 은 text 로 fallback (안전 default)
log.warn("로그 테이블 컬럼 타입 화이트리스트 미일치 → text 로 대체: table={}, col={}, type={}",
safeOrig, safeCol, rawType);
normalized = "text";
}
colDefs.add(String.format("\"%s\" %s", safeCol, normalized));
persistedCols.add(safeCol);
}
if (persistedCols.isEmpty()) {
throw new IllegalArgumentException("log 생성할 컬럼이 없습니다.");
}
String createSql = String.format(
@@ -642,7 +675,7 @@ public class TableManagementService extends BaseService {
Map<String, Object> params = new HashMap<>();
params.put("table_name", tableName);
params.put("is_active", isActive);
params.put("log_columns", String.join(",", targetCols));
params.put("log_columns", String.join(",", persistedCols));
sqlSession.update(NS + "upsertLogConfig", params);
log.info("로그 테이블 생성: {}", safeLog);
@@ -872,19 +905,18 @@ public class TableManagementService extends BaseService {
/**
* context 에 따라 INPUT_TYPE 정규화 및 검증.
* @param context "user-insert" | "user-update-type" | "user-update-other" | "system-normalize"
*/
private String normalizeInputType(String value, String context) {
if ("user-insert".equals(context) || "user-update-type".equals(context)) {
if (value == null || !USER_SELECTABLE_INPUT_TYPES.contains(value)) {
private String normalizeInputType(String value, InputTypeContext context) {
if (context == InputTypeContext.USER_INSERT || context == InputTypeContext.USER_UPDATE_TYPE) {
if (value == null || !InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(value)) {
throw new IllegalArgumentException(
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + USER_SELECTABLE_INPUT_TYPES
"INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
+ " (받은 값: " + value + ")"
);
}
return value;
}
// user-update-other / system-normalize: 기존 동작 그대로
// USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
return normalizeInputType(value);
}