diff --git a/build.gradle b/build.gradle index e367af4..2d286a5 100644 --- a/build.gradle +++ b/build.gradle @@ -87,6 +87,10 @@ dependencies { implementation('org.springframework.boot:spring-boot-starter-thymeleaf') { exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time' } + // WW-5417 관련 public 필드 접근 권한 검사 누락 수정(OGNL #264/#265). + // 3.3.x EOL 계열의 단기 조치. 3.4.x는 Thymeleaf 3.1.5의 OgnlContext 생성자와 비호환. + // 일반 OGNL 경로도 ThymeleafExpressionCompatibilityTest로 검증한다(Spring EL만으로는 확인 불가). + implementation 'ognl:ognl:3.3.5' implementation 'org.springframework.boot:spring-boot-starter-security' implementation('org.springframework.boot:spring-boot-starter-cache') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -190,13 +194,13 @@ ext { // 2.18.x 마지막 패치를 쓴다. set('jackson-bom.version', '2.18.10') - // Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라 - // 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 / - // extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도 - // org.thymeleaf.spring5 + javax.servlet 그대로다. - // Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함). + // CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정. + // Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다. + // extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지. + // ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화, + // MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다. // 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException). - set('thymeleaf.version', '3.1.4.RELEASE') + set('thymeleaf.version', '3.1.5.RELEASE') set('thymeleaf-extras-springsecurity.version', '3.1.5.RELEASE') // Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음). diff --git a/djb-docs/cookie.py b/djb-docs/cookie.py new file mode 100644 index 0000000..dfec88e --- /dev/null +++ b/djb-docs/cookie.py @@ -0,0 +1,343 @@ +import argparse +from datetime import datetime +import http.cookiejar +from http.cookies import SimpleCookie +import json +import ssl +import sys +import time +import unicodedata +import urllib.error +import urllib.parse +import urllib.request + + +DEFAULT_HOST = 'https://api.jejubank.co.kr' +COOKIE_NAME = 'JSESSIONID_PORTAL' +USER_AGENT = ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/140.0.0.0 Safari/537.36' +) +COLUMNS = ( + ('회', 3), + ('대상', 4), + ('결과', 10), + ('쿠키', 8), + ('CSRF', 8), + ('HTTP', 4), + ('요청 값', 10), + ('요청 서버ID', 11), + ('응답 값', 10), + ('응답 서버ID', 11), +) +BORDER = '+' + '+'.join('-' * (width + 2) for _, width in COLUMNS) + '+' + + +class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, message, headers, new_url): + # 두 서버 비교에서는 지정한 주소의 첫 응답만 검사한다. + # 공유 세션 쿠키를 리다이렉트 대상에 전달하지 않는다. + return None + + +def integer_between(minimum, maximum): + def parse(value): + try: + number = int(value) + except ValueError: + raise argparse.ArgumentTypeError( + f'{minimum}~{maximum} 사이의 정수를 입력하세요.' + ) + if not minimum <= number <= maximum: + raise argparse.ArgumentTypeError( + f'{minimum}~{maximum} 사이의 정수를 입력하세요.' + ) + return number + return parse + + +def host_address(value): + value = value.strip() + message = 'http://호스트[:포트] 또는 https://호스트[:포트] 형식으로 입력하세요.' + try: + parsed = urllib.parse.urlsplit(value) + parsed.port # 포트 형식과 범위도 검사한다. + except ValueError: + raise argparse.ArgumentTypeError(message) + if (parsed.scheme not in ('http', 'https') or not parsed.hostname + or parsed.path not in ('', '/') or parsed.query or parsed.fragment + or parsed.username is not None or parsed.password is not None + or any(char.isspace() for char in value)): + raise argparse.ArgumentTypeError(message) + return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, '', '', '')) + + +def parse_args(argv=None): + parser = argparse.ArgumentParser( + description='새 익명 세션으로 쿠키와 CSRF 토큰의 유지 여부를 표로 확인합니다.' + ) + parser.add_argument( + '--host', type=host_address, default=DEFAULT_HOST, + metavar='주소', help=f'대상 호스트 주소 (기본값: {DEFAULT_HOST})', + ) + parser.add_argument( + '--host2', type=host_address, metavar='주소', + help='두 번째 서버. A/B를 번갈아 요청 (리다이렉트 미추적)', + ) + parser.add_argument( + '--cookie-mode', choices=('shared', 'separate'), default='shared', + help='shared: 직전 세션 쿠키 공유, separate: 서버 주소/포트별 저장소 분리 (기본값: shared)', + ) + parser.add_argument( + '-n', '--count', type=integer_between(1, 100), default=10, + metavar='횟수', help='전체 요청 횟수: 1~100회 (기본값: 10회, 두 서버 모드도 합산)', + ) + parser.add_argument( + '-d', '--delay', '--delay-ms', type=integer_between(0, 10000), default=50, + metavar='밀리초', help='요청 사이 대기시간: 0~10000ms (기본값: 50ms)', + ) + tls_options = parser.add_mutually_exclusive_group() + tls_options.add_argument( + '--cacert', metavar='CA파일', + help='신뢰할 사설 CA 인증서 또는 인증서 묶음 파일 (PEM 형식)', + ) + tls_options.add_argument( + '-k', '--insecure', action='store_true', + help='HTTPS 서버 인증서와 호스트명 검증 생략', + ) + return parser.parse_args(argv) + + +def tls_context(args): + context = ssl.create_default_context() + if args.cacert: + context.load_verify_locations(cafile=args.cacert) + if args.insecure: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + +def cookie_from_headers(headers): + value = None + for header in headers: + parsed = SimpleCookie() + parsed.load(header) + if COOKIE_NAME in parsed: + value = parsed[COOKIE_NAME].value + return value + + +def cookie_for_url(jar, url): + request = urllib.request.Request(url) + jar.add_cookie_header(request) + return cookie_from_headers([request.get_header('Cookie', '')]) + + +def host_key(url): + parsed = urllib.parse.urlsplit(url) + port = parsed.port if parsed.port is not None else (443 if parsed.scheme == 'https' else 80) + return parsed.scheme, parsed.hostname, port + + +def carry_session_cookie(request, value): + # 현재 호스트에 해당하는 다른 쿠키는 유지하고 포털 세션 쿠키만 이어 보낸다. + cookies = SimpleCookie() + cookies.load(request.get_header('Cookie', '')) + cookies[COOKIE_NAME] = value + request.add_unredirected_header( + 'Cookie', cookies.output(header='', sep='; ').strip() + ) + + +def server_id(cookie): + return cookie.split('!')[1] if cookie and '!' in cookie else '-' + + +def cell(value, width): + text = str(value) + # 한글은 보통 터미널에서 두 칸을 차지하므로 표시 폭을 기준으로 정렬한다. + display_width = sum( + 0 if unicodedata.combining(char) else + 2 if unicodedata.east_asian_width(char) in ('W', 'F') else 1 + for char in text + ) + return text + ' ' * max(0, width - display_width) + + +def print_row(values): + print('| ' + ' | '.join( + cell(value, width) for value, (_, width) in zip(values, COLUMNS) + ) + ' |', flush=True) + + +def change_state(previous, current): + if previous is None: + return '최초' + return '유지' if previous == current else '!!변경!!' + + +def run_probe(args): + targets = [('A', args.host + '/api/session/csrf')] + if args.host2: + targets.append(('B', args.host2 + '/api/session/csrf')) + try: + context = tls_context(args) + except (OSError, ValueError) as error: + print(f'TLS 설정 실패: {error}', file=sys.stderr) + return 1 + jar = http.cookiejar.CookieJar() + cookie_processor = urllib.request.HTTPCookieProcessor(jar) + handlers = [ + urllib.request.HTTPSHandler(context=context), + cookie_processor, + ] + if args.host2: + handlers.append(NoRedirectHandler()) + client = urllib.request.build_opener(*handlers) + previous_cookie = None + previous_token = None + cookie_stores = {} + session_states = {} + totals = {'최초': 0, '유지': 0, '변경': 0} + completed = 0 + failure = None + exit_code = 0 + + for target, url in targets: + print(f'대상 {target}: {url}') + if len(targets) == 2: + print('호출 순서: A -> B -> A -> B ... (클라이언트 1개, 전체 요청 횟수 기준)') + print('두 서버 비교에서는 3xx 리다이렉트를 따라가지 않습니다.') + if args.cookie_mode == 'separate': + print('쿠키 모드: separate | 서버 주소/포트별 저장소 및 변경 비교 기준 분리') + elif len(targets) == 2: + print(f'쿠키 모드: shared | 직전 {COOKIE_NAME}을 다음 서버로 전달') + if any(url.startswith('https://') for _, url in targets): + tls_mode = ('생략 (--insecure)' if args.insecure else + f'사설 CA 추가 ({args.cacert})' if args.cacert else '기본 CA 사용') + print(f'TLS 인증서 검증: {tls_mode}') + print(f'쿠키 키: {COOKIE_NAME} | 값: 앞 10자리 | 횟수: {args.count}회 | 딜레이: {args.delay}ms') + print(BORDER) + print_row([title for title, _ in COLUMNS]) + print(BORDER, flush=True) + + try: + for number in range(1, args.count + 1): + if number > 1 and args.delay: + time.sleep(args.delay / 1000) + + target, url = targets[(number - 1) % len(targets)] + if args.cookie_mode == 'separate': + key = host_key(url) + if key not in cookie_stores: + cookie_stores[key] = http.cookiejar.CookieJar() + jar = cookie_stores[key] + # 순차 요청마다 같은 클라이언트의 쿠키 저장소만 교체한다. + cookie_processor.cookiejar = jar + previous_cookie, previous_token = session_states.get(key, (None, None)) + request = urllib.request.Request( + url, + headers={ + 'Accept': 'application/json', + 'Cache-Control': 'no-cache', + 'User-Agent': USER_AGENT, + }, + ) + # 응답이 저장소를 갱신하기 전에 실제 전송할 쿠키를 기록한다. + jar.add_cookie_header(request) + if args.host2 and args.cookie_mode == 'shared' and previous_cookie is not None: + carry_session_cookie(request, previous_cookie) + request_cookie = cookie_from_headers([request.get_header('Cookie', '')]) + response_cookie = None + status = '-' + try: + with client.open(request, timeout=10) as response: + status = response.status + response_url = response.geturl() + # 이번 응답의 Set-Cookie만 표시한다. 재발급이 없으면 '없음'. + response_cookie = cookie_from_headers( + response.headers.get_all('Set-Cookie') or [] + ) + data = json.load(response) + + if args.host2 and args.cookie_mode == 'shared' and response_cookie is None: + # 다른 호스트에 직접 이어 보낸 쿠키는 저장소에 없을 수 있다. + # 재발급이 없으면 이번 요청에 실었던 세션을 계속 사용한다. + cookie = request_cookie + else: + cookie = cookie_for_url(jar, response_url) + token = data.get('token') if isinstance(data, dict) else None + if not cookie or not token: + raise ValueError('세션 쿠키 또는 CSRF 토큰 없음') + except urllib.error.HTTPError as error: + status = error.code + response_cookie = cookie_from_headers( + error.headers.get_all('Set-Cookie') or [] + ) + failure = f'{number}회: HTTP {status}' + error.close() + except Exception as error: + failure = f'{number}회: {error}' + + if failure: + result, cookie_state, token_state = '!!실패!!', '-', '-' + exit_code = 1 + else: + # 변경 판정은 앞 10자리가 아니라 전체 쿠키와 전체 토큰으로 비교한다. + cookie_state = change_state(previous_cookie, cookie) + token_state = change_state(previous_token, token) + if '!!변경!!' in (cookie_state, token_state): + outcome, result = '변경', '>>>변경<<<' + elif previous_cookie is None: + outcome, result = '최초', '[최초]' + else: + outcome, result = '유지', '[유지]' + totals[outcome] += 1 + completed += 1 + previous_cookie, previous_token = cookie, token + if args.cookie_mode == 'separate': + session_states[host_key(url)] = (cookie, token) + + print_row(( + number, target, result, cookie_state, token_state, status, + request_cookie[:10] if request_cookie else '없음', + server_id(request_cookie), + response_cookie[:10] if response_cookie else '없음', + server_id(response_cookie), + )) + if failure: + break + except KeyboardInterrupt: + failure = '사용자가 중단했습니다.' + exit_code = 130 + + print(BORDER) + print(f'정상 조회: {completed}/{args.count}회 | 최초: {totals["최초"]}회 | 유지: {totals["유지"]}회 | 변경: {totals["변경"]}회') + print('응답 값=없음: Set-Cookie 재발급 없음. 서버ID는 각 쿠키의 ! 뒤 식별값입니다.') + if failure: + print(f'실패/중단: {failure}') + return exit_code + + +def main(argv=None): + args = parse_args(argv) + started_counter = time.perf_counter() + started_at = datetime.now().astimezone() + print(f'시작 시각: {started_at.isoformat(sep=" ", timespec="milliseconds")}', flush=True) + try: + return run_probe(args) + except KeyboardInterrupt: + print('실패/중단: 사용자가 중단했습니다.') + return 130 + finally: + finished_at = datetime.now().astimezone() + # 시스템 시각 보정에 영향받지 않도록 경과 시간은 별도 시계로 측정한다. + elapsed = time.perf_counter() - started_counter + print(f'종료 시각: {finished_at.isoformat(sep=" ", timespec="milliseconds")}') + print(f'총 소요 시간: {elapsed:.3f}초 (요청 간 딜레이 포함)', flush=True) + + +if __name__ == '__main__': + sys.exit(main())