test #462: add VPD predicate injection checks

This commit is contained in:
devmrko
2026-06-25 22:05:24 +09:00
parent 2379e2c9ec
commit 8fddf1033e
2 changed files with 162 additions and 11 deletions

View File

@@ -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은 별도 보안 테스트 체계가 필요할 때 확장한다.

View File

@@ -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;
/