쿠키 및 CSRF 토큰 유지 여부 검사 스크립트 추가:
- 새로운 익명 세션에서 두 서버 간 응답 비교 기능 구현 - Python 기반 검사기 `cookie.py` 작성 및 주요 옵션 제공
This commit is contained in:
+10
-6
@@ -87,6 +87,10 @@ dependencies {
|
|||||||
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
implementation('org.springframework.boot:spring-boot-starter-thymeleaf') {
|
||||||
exclude group: 'org.thymeleaf.extras', module: 'thymeleaf-extras-java8time'
|
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-security'
|
||||||
implementation('org.springframework.boot:spring-boot-starter-cache')
|
implementation('org.springframework.boot:spring-boot-starter-cache')
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
@@ -190,13 +194,13 @@ ext {
|
|||||||
// 2.18.x 마지막 패치를 쓴다.
|
// 2.18.x 마지막 패치를 쓴다.
|
||||||
set('jackson-bom.version', '2.18.10')
|
set('jackson-bom.version', '2.18.10')
|
||||||
|
|
||||||
// Thymeleaf SSTI (≤3.1.3.RELEASE: 표현식 접근 객체 제한 우회 → 템플릿 인젝션). 3.0.x 는 EOL 이라
|
// CVE-2026-41901: ≤3.1.4의 제한된 표현식 구문 검사 우회(SSTI)를 3.1.5에서 수정.
|
||||||
// 백포트가 없어 3.1.4 로 올린다. JDK8/Spring5 유지: thymeleaf 3.1.4 / thymeleaf-spring5 3.1.4 /
|
// Boot 의존성 관리로 core/spring5를 함께 맞추며 Java8 / Spring5 / javax.servlet을 유지한다.
|
||||||
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0 모두 Java8 바이트코드(major 52), 패키지도
|
// extras-springsecurity5 3.1.5 / layout-dialect 3.4.0은 유지.
|
||||||
// org.thymeleaf.spring5 + javax.servlet 그대로다.
|
// ThymeleafBootMvcCompatibilityTest가 Boot 2.7 자동 구성 엔진·ViewResolver의 초기화,
|
||||||
// Boot 2.7 ThymeleafAutoConfiguration 이 호출하는 setter 는 3.1.4 에 전부 존재함(확인함).
|
// MVC 폼·레이아웃·보안 표시를 검증한다. 실제 WAS 기동/재배포 검증은 별도 배포 조건이다.
|
||||||
// 주의: 3.1 은 #request/#session/#response/#servletContext 표현식 객체를 제거했다(IllegalArgumentException).
|
// 주의: 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')
|
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 혼재였음).
|
// Spring Framework 5.3.x OSS 마지막 릴리스로 통일(Boot 2.7.18 BOM 기본 5.3.31, 일부 5.3.30 혼재였음).
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user