Relax standard header validation and preserve optional values
Some checks failed
Deploy Gateway / deploy (push) Failing after 0s
Some checks failed
Deploy Gateway / deploy (push) Failing after 0s
This commit is contained in:
28
README.md
28
README.md
@@ -80,22 +80,22 @@ Tool 실행 주소는 local `_meta.endpoint` 또는 운영 `baseEndpoint` 설정
|
||||
|
||||
## Correlation과 로그
|
||||
|
||||
Agent Builder가 보내는 표준 헤더는 12개다. MCP는 권한 판정을 하지 않지만, 필수값·형식·요청 시각 오차는 HTTP 입력 경계에서 검증한다.
|
||||
Agent Builder가 보낼 수 있는 표준 헤더는 12개다. MCP는 권한 판정이나 헤더 업무 검증을 하지 않고, 표준 헤더를 필수로 요구하지 않는다. 값이 없으면 없는 상태로 두며, 이 표준 헤더 때문에 요청을 차단하지 않는다.
|
||||
|
||||
| 헤더 | 형식/조건 | MCP 동작 |
|
||||
| 헤더 | 기대 형식 | MCP 동작 |
|
||||
|---|---|---|
|
||||
| `X-Guid` | UUID V4 | end-to-end 상관 값으로 응답과 Tool Service 호출에 이어서 사용 |
|
||||
| `X-Praf-No` | 숫자 8자리. `BATCH`는 공란 허용 | Tool Service로 전달. 권한 판단은 하지 않음 |
|
||||
| `X-Request-Id` | UUID V4 | Agent→MCP 요청 식별자. Tool Service 호출 시에는 새 UUID V4로 재채번 |
|
||||
| `X-Request-Time` | ISO-8601 offset date-time, 서버 시각 ±5분 | Agent→MCP 요청 시각. Tool Service 호출 시에는 재채번 시각으로 교체 |
|
||||
| `X-Vrtl-Praf-No` | `X-Praf-No`가 `0986`으로 시작하면 숫자 8자리 필수, 그 외에는 헤더 제거 | 조건만 검증하고 Tool Service로 전달 |
|
||||
| `X-App-Code` | 대문자 3자리 | Tool Service로 전달 |
|
||||
| `X-Project-Code` | 대문자 5자리 | Tool Service로 전달 |
|
||||
| `X-User-Ip` | 사용자 단말 IP. `BATCH`는 공란 허용 | Tool Service로 전달 |
|
||||
| `X-Caller-IP` | 호출 서버 IP | Tool Service 호출 시 MCP 서버 IP로 교체 |
|
||||
| `X-Caller-Host` | 호출 서버 host name | Tool Service 호출 시 MCP 서버 host로 교체 |
|
||||
| `X-Channel` | `SCRN`, `BATCH`, `MSGR`, `MCP`, `EAI` | Tool Service 호출 시 `MCP`로 교체 |
|
||||
| `X-Agent-Id` | API 호출이면 생략 가능 | 있으면 Tool Service로 전달 |
|
||||
| `X-Guid` | UUID V4 | 있으면 end-to-end 상관 값으로 응답과 Tool Service 호출에 이어서 사용하고, 없으면 생략 |
|
||||
| `X-Praf-No` | 숫자 8자리 | 있으면 Tool Service로 전달. 권한 판단은 하지 않음 |
|
||||
| `X-Request-Id` | UUID V4 | 있으면 Agent→MCP 요청 식별자로 사용. Tool Service 호출 시에는 새 UUID V4로 재채번 |
|
||||
| `X-Request-Time` | ISO-8601 offset date-time | 있으면 수신 context에 보관. Tool Service 호출 시에는 재채번 시각으로 교체 |
|
||||
| `X-Vrtl-Praf-No` | 숫자 8자리 | 있으면 Tool Service로 전달 |
|
||||
| `X-App-Code` | 대문자 3자리 | 있으면 Tool Service로 전달 |
|
||||
| `X-Project-Code` | 대문자 5자리 | 있으면 Tool Service로 전달 |
|
||||
| `X-User-Ip` | 사용자 단말 IP | 있으면 Tool Service로 전달 |
|
||||
| `X-Caller-IP` | 호출 서버 IP | 있으면 수신 context에 보관. Tool Service 호출 시 MCP 서버 IP로 교체 |
|
||||
| `X-Caller-Host` | 호출 서버 host name | 있으면 수신 context에 보관. Tool Service 호출 시 MCP 서버 host로 교체 |
|
||||
| `X-Channel` | 채널 코드 | 있으면 수신 context에 보관. Tool Service 호출 시 `MCP`로 교체 |
|
||||
| `X-Agent-Id` | Agent 식별자 | 있으면 Tool Service로 전달 |
|
||||
|
||||
MCP는 사번·가상사번·사용자 IP를 로그에 남기지 않는다. MDC는 사용하지 않으며 로그에는 `X-Guid`와 MCP가 받은 `X-Request-Id`만 남긴다. request/response body와 credential도 기본 로그에 남기지 않는다.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public record McpProperties(
|
||||
@Pattern(
|
||||
regexp = "^/mcp(?:/[a-z0-9-]+)?$",
|
||||
message = "must be /mcp or /mcp/<lowercase-alphanumeric-hyphen>")
|
||||
String endpointPath,
|
||||
String endpointPath,
|
||||
@Valid Server server,
|
||||
@Valid Registry registry,
|
||||
@Valid ToolClient toolClient,
|
||||
|
||||
@@ -170,7 +170,7 @@ public class HttpToolClient implements ToolClient {
|
||||
|
||||
/**
|
||||
* 값이 null이 아닐 때만 HTTP 헤더를 설정해 문자열 `null`이 전달되지 않게 합니다.
|
||||
* BATCH에서 공란으로 들어온 사번·사용자 IP는 빈 문자열 그대로 전달해 호출 성격을 유지합니다.
|
||||
* Agent Builder가 보내지 않은 선택 표준 헤더는 Tool Service 호출에서도 제외합니다.
|
||||
*/
|
||||
private void set(org.springframework.http.HttpHeaders headers, String name, String value) {
|
||||
if (value != null) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -26,10 +27,11 @@ import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 배포 설정의 단일 MCP HTTP 경로에서 요청·응답 경계를 처리하는 표준 Servlet 필터입니다.
|
||||
* Agent Builder가 보낸 guid와 개별 HTTP requestId를 context와 응답 헤더에 연결하고, 요청 크기와 protocol version을 Controller 전에 검증합니다.
|
||||
* Agent Builder가 보낸 guid와 개별 HTTP requestId가 있으면 context와 응답 헤더에 연결하고, 요청 크기와 protocol version을 Controller 전에 검증합니다.
|
||||
* Spring {@code OncePerRequestFilter} 초기화 경로를 사용하지 않기 위해 {@link Filter}를 직접 구현하며, 중복 실행 방어는 request attribute로 수행합니다.
|
||||
* 주요 의존성은 endpoint 설정, header 추출기, JSON mapper, protocol validator와 {@link TraceLogger}입니다.
|
||||
*/
|
||||
@@ -122,8 +124,8 @@ public class McpExchangeFilter implements Filter {
|
||||
McpRequestContext context = headerExtractor.extract(request);
|
||||
validateKnownRoute(context, request);
|
||||
McpRequestContextHolder.set(context);
|
||||
response.setHeader(McpRequestContextFactory.HEADER_GUID, context.guid());
|
||||
response.setHeader(McpRequestContextFactory.HEADER_REQUEST_ID, context.requestId());
|
||||
setResponseHeaderIfPresent(response, McpRequestContextFactory.HEADER_GUID, context.guid());
|
||||
setResponseHeaderIfPresent(response, McpRequestContextFactory.HEADER_REQUEST_ID, context.requestId());
|
||||
|
||||
CachedBodyHttpServletRequest cachedRequest =
|
||||
new CachedBodyHttpServletRequest(request, properties.trace().maxBodyBytes());
|
||||
@@ -263,13 +265,34 @@ public class McpExchangeFilter implements Filter {
|
||||
throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
objectMapper.writeValue(
|
||||
objectMapper.writeValue(
|
||||
response.getOutputStream(),
|
||||
Map.of(
|
||||
"error", "Invalid MCP protocol version",
|
||||
"message", exception.getMessage(),
|
||||
"supportedVersions", properties.protocol().supportedVersions(),
|
||||
"guid", context.guid()));
|
||||
protocolVersionErrorBody(context, exception));
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 표준 헤더가 실제로 들어온 경우에만 HTTP 응답 header로 되돌려 줍니다.
|
||||
* MCP는 12개 표준 헤더의 누락을 보정하거나 필수값으로 판단하지 않으므로, 값이 없으면 header 자체를 쓰지 않습니다.
|
||||
*/
|
||||
private void setResponseHeaderIfPresent(HttpServletResponse response, String name, String value) {
|
||||
if (StringUtils.hasText(value)) {
|
||||
response.setHeader(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* protocol version transport 오류 body를 구성합니다.
|
||||
* {@code X-Guid}가 요청에 포함된 경우에만 추적값을 body에 싣고, 누락된 경우 MCP가 임의 값을 생성하지 않습니다.
|
||||
*/
|
||||
private Map<String, Object> protocolVersionErrorBody(McpRequestContext context, ProtocolVersionException exception) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("error", "Invalid MCP protocol version");
|
||||
body.put("message", exception.getMessage());
|
||||
body.put("supportedVersions", properties.protocol().supportedVersions());
|
||||
if (StringUtils.hasText(context.guid())) {
|
||||
body.put("guid", context.guid());
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,20 +5,16 @@ import io.shinhanlife.dat.biz.mcp.context.McpRequestContext;
|
||||
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcErrorCode;
|
||||
import io.shinhanlife.dat.biz.mcp.jsonrpc.JsonRpcException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 설정된 MCP endpoint의 HTTP 헤더와 동적 route path를 읽어 {@link McpRequestContext}를 만드는 입력 경계 컴포넌트입니다.
|
||||
* filter의 가장 앞 단계에서 호출되며 Agent Builder가 보낸 12개 표준 헤더의 필수 여부와 형식을 검증합니다.
|
||||
* 권한 판정이나 업무 의미 해석은 하지 않고, 검증된 값을 요청 context에 보관해 Tool Service 호출 단계로 넘깁니다.
|
||||
* filter의 가장 앞 단계에서 호출되며 Agent Builder가 보낸 12개 표준 헤더를 선택값으로 추출합니다.
|
||||
* 표준 헤더의 필수 여부나 업무 형식은 판단하지 않고, 전달된 값을 정규화해 Tool Service 호출 단계로 넘깁니다.
|
||||
* route와 MCP session correlation처럼 서버 경계에 필요한 값만 이 컴포넌트에서 검증합니다.
|
||||
*/
|
||||
@Component
|
||||
public class McpRequestContextFactory {
|
||||
@@ -38,18 +34,6 @@ public class McpRequestContextFactory {
|
||||
|
||||
private static final Pattern SAFE_CORRELATION_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
|
||||
private static final Pattern SAFE_ROUTE_KEY = Pattern.compile("[A-Za-z0-9._-]{1,64}");
|
||||
private static final Pattern UUID_V4 = Pattern.compile(
|
||||
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}");
|
||||
private static final Pattern EIGHT_DIGITS = Pattern.compile("\\d{8}");
|
||||
private static final Pattern APP_CODE = Pattern.compile("[A-Z]{3}");
|
||||
private static final Pattern PROJECT_CODE = Pattern.compile("[A-Z]{5}");
|
||||
private static final Pattern CALLER_HOST = Pattern.compile("[A-Za-z0-9._-]{1,255}");
|
||||
private static final Pattern IPV4 = Pattern.compile(
|
||||
"(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}");
|
||||
private static final Pattern IPV6_LIKE = Pattern.compile("[0-9A-Fa-f:.%]{2,64}");
|
||||
private static final Pattern SAFE_AGENT_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
|
||||
private static final Set<String> CHANNELS = Set.of("SCRN", "BATCH", "MSGR", "MCP", "EAI");
|
||||
private static final Duration REQUEST_TIME_TOLERANCE = Duration.ofMinutes(5);
|
||||
|
||||
private final McpProperties properties;
|
||||
|
||||
@@ -62,25 +46,25 @@ public class McpRequestContextFactory {
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청에서 route key와 표준 헤더를 추출해 불변 context로 만듭니다.
|
||||
* 누락·형식 오류·요청 시각 오차는 controller 진입 전에 JSON-RPC invalid request 예외로 거부하고, 권한 관련 판단은 하지 않습니다.
|
||||
* HTTP 요청에서 route key와 선택 표준 헤더를 추출해 불변 context로 만듭니다.
|
||||
* 12개 표준 헤더는 MCP가 필수 여부나 형식을 판단하지 않으며, 없으면 {@code null}로 둡니다.
|
||||
* 이 메서드에서 차단하는 값은 route key와 MCP session correlation처럼 MCP transport 경계가 직접 소유한 값뿐입니다.
|
||||
*/
|
||||
public McpRequestContext extract(HttpServletRequest request) {
|
||||
String routeKey = routeKey(request);
|
||||
String authorization = trimToNull(request.getHeader("Authorization"));
|
||||
String channel = requiredEnum(request, HEADER_CHANNEL, CHANNELS);
|
||||
boolean batch = "BATCH".equals(channel);
|
||||
String guid = requiredUuidV4(request, HEADER_GUID);
|
||||
String requestId = requiredUuidV4(request, HEADER_REQUEST_ID);
|
||||
String requestTime = requiredRequestTime(request);
|
||||
String prafNo = employeeNo(request, HEADER_PRAF_NO, batch);
|
||||
String virtualPrafNo = virtualPrafNo(request, prafNo, batch);
|
||||
String appCode = requiredPattern(request, HEADER_APP_CODE, APP_CODE, "must be 3 uppercase letters");
|
||||
String projectCode = requiredPattern(request, HEADER_PROJECT_CODE, PROJECT_CODE, "must be 5 uppercase letters");
|
||||
String userIp = ipAddress(request, HEADER_USER_IP, batch);
|
||||
String callerIp = ipAddress(request, HEADER_CALLER_IP, false);
|
||||
String callerHost = requiredPattern(request, HEADER_CALLER_HOST, CALLER_HOST, "must be a safe host name");
|
||||
String agentId = optionalPattern(request, HEADER_AGENT_ID, SAFE_AGENT_ID, "must be a safe identifier");
|
||||
String channel = trimToNull(request.getHeader(HEADER_CHANNEL));
|
||||
String guid = trimToNull(request.getHeader(HEADER_GUID));
|
||||
String requestId = trimToNull(request.getHeader(HEADER_REQUEST_ID));
|
||||
String requestTime = trimToNull(request.getHeader(HEADER_REQUEST_TIME));
|
||||
String prafNo = trimToNull(request.getHeader(HEADER_PRAF_NO));
|
||||
String virtualPrafNo = trimToNull(request.getHeader(HEADER_VRTL_PRAF_NO));
|
||||
String appCode = trimToNull(request.getHeader(HEADER_APP_CODE));
|
||||
String projectCode = trimToNull(request.getHeader(HEADER_PROJECT_CODE));
|
||||
String userIp = trimToNull(request.getHeader(HEADER_USER_IP));
|
||||
String callerIp = trimToNull(request.getHeader(HEADER_CALLER_IP));
|
||||
String callerHost = trimToNull(request.getHeader(HEADER_CALLER_HOST));
|
||||
String agentId = trimToNull(request.getHeader(HEADER_AGENT_ID));
|
||||
String sessionId = validatedOptional(request.getHeader("mcp-session-id"), "mcp-session-id");
|
||||
|
||||
return new McpRequestContext(
|
||||
@@ -161,164 +145,6 @@ public class McpRequestContextFactory {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 UUID V4 헤더를 읽어 canonical 형식인지 검증합니다.
|
||||
* 누락되거나 UUID V4가 아니면 요청 추적 계약 위반으로 보고 invalid request 예외를 발생시킵니다.
|
||||
*/
|
||||
private String requiredUuidV4(HttpServletRequest request, String header) {
|
||||
String value = required(request, header);
|
||||
if (!UUID_V4.matcher(value).matches()) {
|
||||
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be a UUID v4");
|
||||
}
|
||||
try {
|
||||
return UUID.fromString(value).toString();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be a UUID v4");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code X-Request-Time}을 ISO-8601 offset 시각으로 파싱하고 현재 시각과 5분 이상 차이 나는 요청을 거부합니다.
|
||||
* request replay나 오래 지연된 호출이 Tool Service까지 전달되지 않도록 입력 경계에서 차단합니다.
|
||||
*/
|
||||
private String requiredRequestTime(HttpServletRequest request) {
|
||||
String value = required(request, HEADER_REQUEST_TIME);
|
||||
try {
|
||||
Instant requestInstant = OffsetDateTime.parse(value).toInstant();
|
||||
Duration skew = Duration.between(requestInstant, Instant.now()).abs();
|
||||
if (skew.compareTo(REQUEST_TIME_TOLERANCE) > 0) {
|
||||
throw new JsonRpcException(
|
||||
JsonRpcErrorCode.INVALID_REQUEST,
|
||||
HEADER_REQUEST_TIME + " must be within 5 minutes of server time");
|
||||
}
|
||||
return value;
|
||||
} catch (DateTimeParseException exception) {
|
||||
throw new JsonRpcException(
|
||||
JsonRpcErrorCode.INVALID_REQUEST,
|
||||
HEADER_REQUEST_TIME + " must be ISO-8601 offset date-time");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사번 헤더를 검증합니다.
|
||||
* 화면·메신저·MCP·EAI 호출은 숫자 8자리를 요구하고, 사람이 없는 BATCH 호출은 공란 또는 누락을 허용합니다.
|
||||
*/
|
||||
private String employeeNo(HttpServletRequest request, String header, boolean batch) {
|
||||
String value = valueOrEmpty(request.getHeader(header));
|
||||
if (batch && value.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (!EIGHT_DIGITS.matcher(value).matches()) {
|
||||
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be 8 digits");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 협력사 사번의 가상사번 조건을 검증합니다.
|
||||
* {@code 0986}으로 시작하는 사번은 가상사번을 필수로 받고, 그 외 호출에서는 빈 문자열을 포함해 전달된 헤더 값을 거부합니다.
|
||||
*/
|
||||
private String virtualPrafNo(HttpServletRequest request, String prafNo, boolean batch) {
|
||||
boolean present = hasHeader(request, HEADER_VRTL_PRAF_NO);
|
||||
String value = valueOrEmpty(request.getHeader(HEADER_VRTL_PRAF_NO));
|
||||
if (!batch && prafNo.startsWith("0986")) {
|
||||
if (!EIGHT_DIGITS.matcher(value).matches()) {
|
||||
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, HEADER_VRTL_PRAF_NO + " is required for partner employee");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (present) {
|
||||
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, HEADER_VRTL_PRAF_NO + " must be absent for non-partner employee");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 헤더를 검증합니다.
|
||||
* BATCH의 사용자 IP만 공란 또는 누락을 허용하고, 그 외 IP는 IPv4 또는 IPv6 형태의 안전한 값만 통과시킵니다.
|
||||
*/
|
||||
private String ipAddress(HttpServletRequest request, String header, boolean blankAllowed) {
|
||||
String value = valueOrEmpty(request.getHeader(header));
|
||||
if (blankAllowed && value.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (!isIpAddress(value)) {
|
||||
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " must be an IP address");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 헤더 값을 지정한 정규식으로 검증합니다.
|
||||
* 값이 없거나 형식이 다르면 Tool 실행 전 단계에서 invalid request 예외를 발생시킵니다.
|
||||
*/
|
||||
private String requiredPattern(HttpServletRequest request, String header, Pattern pattern, String message) {
|
||||
String value = required(request, header);
|
||||
if (!pattern.matcher(value).matches()) {
|
||||
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " " + message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 헤더가 있을 때만 지정한 정규식으로 검증합니다.
|
||||
* Agent ID처럼 API 호출에서는 빠질 수 있는 값은 누락을 허용하되, 전달된 값의 header injection 위험은 차단합니다.
|
||||
*/
|
||||
private String optionalPattern(HttpServletRequest request, String header, Pattern pattern, String message) {
|
||||
String value = trimToNull(request.getHeader(header));
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (!pattern.matcher(value).matches()) {
|
||||
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " " + message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 enum 헤더가 허용된 코드 집합에 포함되는지 검증합니다.
|
||||
* 현재는 화면·배치·메신저·MCP·EAI 채널만 허용합니다.
|
||||
*/
|
||||
private String requiredEnum(HttpServletRequest request, String header, Set<String> allowed) {
|
||||
String value = required(request, header);
|
||||
if (!allowed.contains(value)) {
|
||||
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " is invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 헤더를 공백 제거 후 반환합니다.
|
||||
* 누락되거나 공백뿐이면 표준 헤더 계약 위반으로 보고 invalid request 예외를 발생시킵니다.
|
||||
*/
|
||||
private String required(HttpServletRequest request, String header) {
|
||||
String value = trimToNull(request.getHeader(header));
|
||||
if (value == null) {
|
||||
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, header + " is required");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청에 특정 헤더가 실제로 포함되어 있었는지 확인합니다.
|
||||
* 비협력사 호출에서 빈 가상사번 헤더를 보낸 경우도 제거되지 않은 것으로 판단하기 위해 값 조회와 별도로 사용합니다.
|
||||
*/
|
||||
private boolean hasHeader(HttpServletRequest request, String header) {
|
||||
return request.getHeaderNames().asIterator().hasNext()
|
||||
&& request.getHeaders(header).asIterator().hasNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* IPv4는 octet 범위를 엄격히 확인하고, IPv6는 주소 문자 집합과 콜론 포함 여부를 기준으로 안전한 문자열만 허용합니다.
|
||||
* DNS 조회는 하지 않아 요청 처리 지연이나 외부 의존성을 만들지 않습니다.
|
||||
*/
|
||||
private boolean isIpAddress(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return false;
|
||||
}
|
||||
return IPV4.matcher(value).matches() || (value.contains(":") && IPV6_LIKE.matcher(value).matches());
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 header 값이 있을 때만 correlation 형식 검증을 수행합니다.
|
||||
* 값이 없으면 호출자가 header를 보내지 않은 것으로 보고 {@code null}을 반환합니다.
|
||||
@@ -350,12 +176,4 @@ public class McpRequestContextFactory {
|
||||
private String trimToNull(String value) {
|
||||
return StringUtils.hasText(value) ? value.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* null은 빈 문자열로 정규화하되, 입력값의 공백은 보존합니다.
|
||||
* 사번과 IP처럼 정확한 형식이 필요한 헤더에서 공백 포함 값을 실수로 허용하지 않기 위해 사용합니다.
|
||||
*/
|
||||
private String valueOrEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory
|
||||
import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_REQUEST_ID;
|
||||
import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_REQUEST_TIME;
|
||||
import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_USER_IP;
|
||||
import static io.shinhanlife.dat.biz.mcp.transport.http.McpRequestContextFactory.HEADER_VRTL_PRAF_NO;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
@@ -74,10 +73,10 @@ class McpExchangeFilterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingRequiredStandardHeaderBeforeController() throws Exception {
|
||||
void acceptsRequestWithoutStandardHeadersWithoutGeneratingCorrelationValues() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
request.addHeader(HEADER_GUID, GUID);
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.setContent(
|
||||
"""
|
||||
{"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
|
||||
@@ -88,12 +87,16 @@ class McpExchangeFilterTest {
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(ignoredRequest, ignoredResponse) -> {
|
||||
throw new AssertionError("Controller chain must not be called without required headers");
|
||||
(wrappedRequest, wrappedResponse) -> {
|
||||
assertThat(McpRequestContextHolder.require().guid()).isNull();
|
||||
assertThat(McpRequestContextHolder.require().requestId()).isNull();
|
||||
assertThat(McpRequestContextHolder.require().requestTime()).isNull();
|
||||
wrappedResponse.setContentType("application/json");
|
||||
});
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_CHANNEL + " is required");
|
||||
assertThat(response.getHeader(HEADER_GUID)).isNull();
|
||||
assertThat(response.getHeader(HEADER_REQUEST_ID)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -119,8 +122,34 @@ class McpExchangeFilterTest {
|
||||
request,
|
||||
response,
|
||||
(wrappedRequest, wrappedResponse) -> {
|
||||
assertThat(McpRequestContextHolder.require().prafNo()).isEmpty();
|
||||
assertThat(McpRequestContextHolder.require().userIp()).isEmpty();
|
||||
assertThat(McpRequestContextHolder.require().prafNo()).isNull();
|
||||
assertThat(McpRequestContextHolder.require().userIp()).isNull();
|
||||
wrappedResponse.setContentType("application/json");
|
||||
});
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsUnknownChannelCodeWithoutValidation() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
addStandardHeaders(request);
|
||||
request.removeHeader(HEADER_CHANNEL);
|
||||
request.addHeader(HEADER_CHANNEL, "PORTAL_API");
|
||||
request.addHeader("MCP-Protocol-Version", "2025-11-25");
|
||||
request.setContent(
|
||||
"""
|
||||
{"jsonrpc":"2.0","id":"call-1","method":"tools/list","params":{}}
|
||||
"""
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(wrappedRequest, wrappedResponse) -> {
|
||||
assertThat(McpRequestContextHolder.require().channel()).isEqualTo("PORTAL_API");
|
||||
wrappedResponse.setContentType("application/json");
|
||||
});
|
||||
|
||||
@@ -152,7 +181,7 @@ class McpExchangeFilterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmployeeNumberContainingHeaderInjection() throws Exception {
|
||||
void acceptsEmployeeNumberContainingHeaderInjectionWithoutHeaderValidation() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
addStandardHeaders(request);
|
||||
@@ -169,15 +198,16 @@ class McpExchangeFilterTest {
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(ignoredRequest, ignoredResponse) -> {
|
||||
throw new AssertionError("Controller chain must not be called for an unsafe X-Praf-No header");
|
||||
(wrappedRequest, wrappedResponse) -> {
|
||||
assertThat(McpRequestContextHolder.require().prafNo()).isEqualTo("12345678\r\nx-injected: evil");
|
||||
wrappedResponse.setContentType("application/json");
|
||||
});
|
||||
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_PRAF_NO + " must be 8 digits");
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmployeeNumberContainingWhitespaceInsteadOfTrimmingIt() throws Exception {
|
||||
void trimsOptionalEmployeeNumberBeforeValidation() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
addStandardHeaders(request);
|
||||
@@ -194,15 +224,16 @@ class McpExchangeFilterTest {
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(ignoredRequest, ignoredResponse) -> {
|
||||
throw new AssertionError("Controller chain must not be called for an unsafe X-Praf-No header");
|
||||
(wrappedRequest, wrappedResponse) -> {
|
||||
assertThat(McpRequestContextHolder.require().prafNo()).isEqualTo("12345678");
|
||||
wrappedResponse.setContentType("application/json");
|
||||
});
|
||||
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_PRAF_NO + " must be 8 digits");
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsGuidThatIsNotUuidV4() throws Exception {
|
||||
void acceptsGuidThatIsNotUuidV4WithoutHeaderValidation() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
addStandardHeaders(request);
|
||||
@@ -219,15 +250,17 @@ class McpExchangeFilterTest {
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(ignoredRequest, ignoredResponse) -> {
|
||||
throw new AssertionError("Controller chain must not be called for a non-UUID guid");
|
||||
(wrappedRequest, wrappedResponse) -> {
|
||||
assertThat(McpRequestContextHolder.require().guid()).isEqualTo("guid-1");
|
||||
wrappedResponse.setContentType("application/json");
|
||||
});
|
||||
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_GUID + " must be a UUID v4");
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.getHeader(HEADER_GUID)).isEqualTo("guid-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRequestTimeOutsideFiveMinutes() throws Exception {
|
||||
void acceptsRequestTimeOutsidePreviousToleranceWithoutHeaderValidation() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
addStandardHeaders(request);
|
||||
@@ -239,15 +272,16 @@ class McpExchangeFilterTest {
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(ignoredRequest, ignoredResponse) -> {
|
||||
throw new AssertionError("Controller chain must not be called for stale request time");
|
||||
(wrappedRequest, wrappedResponse) -> {
|
||||
assertThat(McpRequestContextHolder.require().requestTime()).isEqualTo("2000-01-01T00:00:00+09:00");
|
||||
wrappedResponse.setContentType("application/json");
|
||||
});
|
||||
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600", "within 5 minutes");
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresVirtualEmployeeNoForPartnerEmployee() throws Exception {
|
||||
void acceptsPartnerEmployeeWithoutVirtualEmployeeNo() throws Exception {
|
||||
McpExchangeFilter filter = filter(properties(false, false));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/mcp");
|
||||
addStandardHeaders(request);
|
||||
@@ -259,11 +293,13 @@ class McpExchangeFilterTest {
|
||||
filter.doFilter(
|
||||
request,
|
||||
response,
|
||||
(ignoredRequest, ignoredResponse) -> {
|
||||
throw new AssertionError("Controller chain must not be called without virtual employee number");
|
||||
(wrappedRequest, wrappedResponse) -> {
|
||||
assertThat(McpRequestContextHolder.require().prafNo()).isEqualTo("09861234");
|
||||
assertThat(McpRequestContextHolder.require().virtualPrafNo()).isNull();
|
||||
wrappedResponse.setContentType("application/json");
|
||||
});
|
||||
|
||||
assertThat(response.getContentAsString()).contains("\"code\":-32600", HEADER_VRTL_PRAF_NO + " is required");
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user