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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user