diff --git a/docs/design/462-predicate-injection-defense/README.md b/docs/design/462-predicate-injection-defense/README.md new file mode 100644 index 0000000..2ffe20e --- /dev/null +++ b/docs/design/462-predicate-injection-defense/README.md @@ -0,0 +1,82 @@ +# 설계서: Predicate injection 및 권한 우회 방어 테스트 (#462) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-06-25 +> **추적성** — Redmine: #462 · 관련 ADR: 없음 +> · 구현 파일: `sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` · 테스트: SQLcl `27`, `./run.sh backoffice-vpd-ords-test`, `mvn test` + +## 1. 목적 (Why) + +VPD predicate function이 DB에 저장된 `rule_column`/`rule_value`를 SQL 문자열로 변환하므로, quote/OR/주석/함수형 컬럼명 입력이 권한 우회로 이어지지 않는지 테스트로 고정한다. + +## 2. 범위 (Scope) + +- **포함**: `rule_value` injection 문자열 escape 검증, `rule_column` injection fail-closed 검증, target mismatch 기본 거부 검증. +- **제외**: UI 입력 fuzzing, ORDS response SQL 노출 정책 변경. + +## 3. 인수조건 (Acceptance Criteria) + +- [ ] `rule_value`에 single quote가 있어도 literal escape가 적용된다. +- [ ] `rule_value`에 `OR 1=1` 또는 `--`가 있어도 predicate 구조를 탈출하지 않는다. +- [ ] `rule_column`에 함수 호출/표현식이 들어가면 해당 rule은 무시되고 `1 = 0`이 된다. +- [ ] `p_object`가 권한의 `target_name`과 다르면 `1 = 0`이 된다. +- [ ] 기존 정상/경계 테스트와 ORDS 회귀 테스트가 유지된다. + +## 4. 컨텍스트 & 제약 + +- `safe_column`은 `DBMS_ASSERT.SIMPLE_SQL_NAME`과 `all_tab_columns` 존재 확인을 사용한다. +- `quote_literal`은 single quote를 두 번으로 escape한다. +- 테스트 데이터는 `456000~456999` 범위를 재사용한다. + +## 5. 아키텍처 개요 + +``` +malicious cb_permission_rule rows + -> cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS') + -> assert predicate contains escaped literal or equals 1 = 0 +``` + +## 6. 데이터 모델 + +- injection users: + - `456107`: quote 포함 값 + - `456108`: `OR 1=1 --` 포함 값 + - `456109`: 함수형 컬럼명 + - `456110`: target mismatch 확인 + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `assert_contains_for_object` | 특정 object에 대한 predicate 포함 검증 | PL/SQL local procedure | label, user, object, expected | 없음 | raise_application_error | 단순 | +| `assert_equals_for_object` | 특정 object에 대한 predicate 일치 검증 | PL/SQL local procedure | label, user, object, expected | 없음 | raise_application_error | 단순 | + +## 8. 흐름 / 알고리즘 + +1. 악성 문자열이 들어간 test user/role/permission/rule을 생성한다. +2. predicate function을 직접 호출한다. +3. 값 기반 injection은 quote escaped literal로 남는지 확인한다. +4. 컬럼 기반 injection은 `1 = 0`이 되는지 확인한다. +5. target mismatch는 `1 = 0`인지 확인한다. + +## 9. 엣지케이스 & 에러 처리 + +- `TITLE = 'HR' OR '1'='1'` 형태 문자열은 전체가 literal 값이어야 한다. +- `DOC_ID) OR 1=1 --` 같은 컬럼명은 컬럼 검증 실패로 무시되어야 한다. +- 모든 rule이 무시되면 whitelist 기본값 `1 = 0`이어야 한다. + +## 10. 테스트 계획 + +- `@sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql` +- `@sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql` +- `./run.sh backoffice-vpd-ords-test` +- `mvn test` + +## 11. 리스크 & 대안 검토 + +- 선택: DB 함수 직접 테스트. 공격 문자열이 실제 predicate 문자열로 어떻게 변환되는지 가장 직접적으로 확인할 수 있다. +- 대안: ORDS만 테스트. 결과 행 수는 확인 가능하지만 predicate escaping 자체를 확인하기 어렵다. + +## 12. 미해결 질문 (Open Questions) + +- 더 넓은 fuzzing은 별도 보안 테스트 체계가 필요할 때 확장한다. diff --git a/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql b/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql index 0261f45..87cd7f0 100644 --- a/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql +++ b/sql/adb/27_agent_ords_security_dynamic_vpd_filter_test.sql @@ -49,12 +49,28 @@ BEGIN INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) VALUES (456106, 'rule_test_bad_column', 'E456106', 'QA', 'N', 'Y'); + INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) + VALUES (456107, 'rule_test_quote_value', 'E456107', 'QA', 'N', 'Y'); + + INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) + VALUES (456108, 'rule_test_or_injection', 'E456108', 'QA', 'N', 'Y'); + + INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) + VALUES (456109, 'rule_test_column_injection', 'E456109', 'QA', 'N', 'Y'); + + INSERT INTO cb_app_user(user_id, user_name, employee_no, dept_code, can_read_contents, active) + VALUES (456110, 'rule_test_target_mismatch', 'E456110', 'QA', 'N', 'Y'); + INSERT INTO cb_app_role(role_id, role_name) VALUES (456101, 'RULE_TEST_MY_DEPT'); INSERT INTO cb_app_role(role_id, role_name) VALUES (456102, 'RULE_TEST_SELF'); INSERT INTO cb_app_role(role_id, role_name) VALUES (456103, 'RULE_TEST_ALL'); INSERT INTO cb_app_role(role_id, role_name) VALUES (456104, 'RULE_TEST_EQ'); INSERT INTO cb_app_role(role_id, role_name) VALUES (456105, 'RULE_TEST_NE'); INSERT INTO cb_app_role(role_id, role_name) VALUES (456106, 'RULE_TEST_BAD_COLUMN'); + INSERT INTO cb_app_role(role_id, role_name) VALUES (456107, 'RULE_TEST_QUOTE_VALUE'); + INSERT INTO cb_app_role(role_id, role_name) VALUES (456108, 'RULE_TEST_OR_INJECTION'); + INSERT INTO cb_app_role(role_id, role_name) VALUES (456109, 'RULE_TEST_COLUMN_INJECTION'); + INSERT INTO cb_app_role(role_id, role_name) VALUES (456110, 'RULE_TEST_TARGET_MISMATCH'); INSERT INTO cb_user_role(user_id, role_id) VALUES (456101, 456101); INSERT INTO cb_user_role(user_id, role_id) VALUES (456102, 456102); @@ -62,6 +78,10 @@ BEGIN INSERT INTO cb_user_role(user_id, role_id) VALUES (456104, 456104); INSERT INTO cb_user_role(user_id, role_id) VALUES (456105, 456105); INSERT INTO cb_user_role(user_id, role_id) VALUES (456106, 456106); + INSERT INTO cb_user_role(user_id, role_id) VALUES (456107, 456107); + INSERT INTO cb_user_role(user_id, role_id) VALUES (456108, 456108); + INSERT INTO cb_user_role(user_id, role_id) VALUES (456109, 456109); + INSERT INTO cb_user_role(user_id, role_id) VALUES (456110, 456110); INSERT INTO cb_permission(perm_id, role_id, target_name, action_name) VALUES (456101, 456101, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); @@ -81,6 +101,18 @@ BEGIN INSERT INTO cb_permission(perm_id, role_id, target_name, action_name) VALUES (456106, 456106, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name) + VALUES (456107, 456107, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); + + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name) + VALUES (456108, 456108, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); + + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name) + VALUES (456109, 456109, 'CB_V_SEARCH_DOCUMENTS', 'SELECT'); + + INSERT INTO cb_permission(perm_id, role_id, target_name, action_name) + VALUES (456110, 456110, 'BOARD_POSTS', 'SELECT'); + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) VALUES (456101, 456101, NULL, 'MY_DEPT', NULL); @@ -99,6 +131,18 @@ BEGIN INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) VALUES (456106, 456106, 'NO_SUCH_COLUMN', '=', 'x'); + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (456107, 456107, 'TITLE', '=', 'Bob''s plan'); + + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (456108, 456108, 'TITLE', '=', 'HR'' OR ''1''=''1'' --'); + + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (456109, 456109, 'DOC_ID) OR 1=1 --', '=', '1'); + + INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value) + VALUES (456110, 456110, NULL, 'ALL', NULL); + COMMIT; END; / @@ -114,9 +158,10 @@ DECLARE ); END; - PROCEDURE assert_equals( + PROCEDURE assert_equals_for_object( p_label IN VARCHAR2, p_user_id IN NUMBER, + p_object IN VARCHAR2, p_expected IN VARCHAR2 ) IS v_actual VARCHAR2(32767); @@ -127,7 +172,7 @@ DECLARE cb_agent_ctx_pkg.set_user(p_user_id); END IF; - v_actual := cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS'); + v_actual := cb_agent_doc_vpd_filter('ADMIN', p_object); IF v_actual != p_expected THEN fail(p_label, p_expected, v_actual); @@ -136,21 +181,40 @@ DECLARE DBMS_OUTPUT.PUT_LINE('PASS ' || p_label || ': ' || v_actual); END; + PROCEDURE assert_equals( + p_label IN VARCHAR2, + p_user_id IN NUMBER, + p_expected IN VARCHAR2 + ) IS + BEGIN + assert_equals_for_object(p_label, p_user_id, 'CB_V_SEARCH_DOCUMENTS', p_expected); + END; + + PROCEDURE assert_contains_for_object( + p_label IN VARCHAR2, + p_user_id IN NUMBER, + p_object IN VARCHAR2, + p_expected IN VARCHAR2 + ) IS + v_actual VARCHAR2(32767); + BEGIN + cb_agent_ctx_pkg.set_user(p_user_id); + v_actual := cb_agent_doc_vpd_filter('ADMIN', p_object); + + IF INSTR(v_actual, p_expected) = 0 THEN + fail(p_label, p_expected, v_actual); + END IF; + + DBMS_OUTPUT.PUT_LINE('PASS ' || p_label || ': ' || v_actual); + END; + PROCEDURE assert_contains( p_label IN VARCHAR2, p_user_id IN NUMBER, p_expected IN VARCHAR2 ) IS - v_actual VARCHAR2(32767); BEGIN - cb_agent_ctx_pkg.set_user(p_user_id); - v_actual := cb_agent_doc_vpd_filter('ADMIN', 'CB_V_SEARCH_DOCUMENTS'); - - IF INSTR(v_actual, p_expected) = 0 THEN - fail(p_label, p_expected, v_actual); - END IF; - - DBMS_OUTPUT.PUT_LINE('PASS ' || p_label || ': ' || v_actual); + assert_contains_for_object(p_label, p_user_id, 'CB_V_SEARCH_DOCUMENTS', p_expected); END; BEGIN assert_equals('NO_CONTEXT_DENIES', NULL, '1 = 0'); @@ -160,6 +224,11 @@ BEGIN assert_contains('EQUALS_RULE', 456104, 'TO_CHAR(DOC_ID) = ''1'''); assert_contains('NOT_EQUALS_RULE', 456105, 'TO_CHAR(DEPT_CODE) <> ''HR'''); assert_equals('BAD_COLUMN_DENIES', 456106, '1 = 0'); + assert_contains('QUOTE_VALUE_ESCAPED', 456107, 'TO_CHAR(TITLE) = ''Bob''''s plan'''); + assert_contains('OR_INJECTION_STAYS_LITERAL', 456108, 'TO_CHAR(TITLE) = ''HR'''' OR ''''1''''=''''1'''' --'''); + assert_equals('COLUMN_INJECTION_DENIES', 456109, '1 = 0'); + assert_equals_for_object('TARGET_MISMATCH_DENIES', 456110, 'CB_V_SEARCH_DOCUMENTS', '1 = 0'); + assert_equals_for_object('TARGET_MATCH_ALL_ALLOWS', 456110, 'BOARD_POSTS', '1 = 1'); cb_agent_ctx_pkg.clear_user; END; /