Tool inputSchema의 문서 밖 참조와 정규식 폭증을 수신 시점에 차단한다
MCP Java SDK 도입으로 들어온 json-schema-validator는 schema의 $ref가 문서
밖을 가리키면 그 주소로 직접 조회하고, pattern 검증을 백트래킹 기반
java.util.regex로 처리한다. inputSchema는 Tool Service 매니페스트에서 오므로
매니페스트가 서버의 outbound 대상과 CPU 소비를 정할 수 있었다. AGENTS.md의
"outbound 주소는 설정에서만 온다"는 불변식이 이 경로에서 뚫려 있었다.
DefaultJsonSchemaValidator는 SchemaRegistry를 생성자 안에서 만들고 private
final로 들고 있어 정책 주입 지점이 없다. 따라서 SDK 밖에서만 막을 수 있다.
검사는 ToolMetadata의 표준 생성자에 둔다. Portal 매니페스트, local 파일,
Redis snapshot 역직렬화가 모두 이 생성자를 지나므로 우회 경로가 생기지 않는다.
위반은 기존 매니페스트 형식 오류와 같게 다뤄 bundle 단위 실패 격리와
"Redis 실패는 언제나 cache miss" 동작을 그대로 유지한다.
정규식 규칙은 JDK 21.0.11 실측으로 정했다. 통념과 달리 (a+)+는 빠르게 끝나고,
중첩이 아닌 a*a*a*a*a*b와 바깥 반복이 유한한 (.*,){11}P가 폭증했다. 겹치는
문자 집합 판정은 결정 불가능하므로 모양 검사만으로는 부족하고, pattern 필드에
maxLength 동반 선언을 요구해 입력 길이를 묶는 것이 실질적인 상한이 된다.
patternProperties는 key에 길이를 선언할 자리가 없어 사용을 금지한다.
format은 단언되지 않아 format:regex 경로가 실행되지 않는다는 사실도 계약
테스트로 고정했다. SDK 업그레이드로 단언이 켜지면 테스트가 실패한다.
Tool Service는 pattern을 쓰는 필드에 maxLength(<=256)를 선언해야 하므로
매니페스트 수용 조건이 바뀐다. Tool Service 파트와 합의가 필요하다.
근거: ADR-0011, ADR-0012
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,8 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. local {@code tools/list} 파일에서 온 경우 {@code publicDefinition}은 공개 필드를 보존하고,
|
||||
* {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다.
|
||||
* {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다. 생성 시점에 {@link ToolSchemaReferencePolicy}와 {@link ToolSchemaPatternPolicy}로
|
||||
* {@code inputSchema}를 검사하므로, 어느 조회 경로로 들어온 metadata든 문서 밖을 가리키는 참조나 되돌아오는 데 오래 걸리는 정규식을 담은 채로는 만들어지지 않습니다.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ToolMetadata(
|
||||
@@ -19,6 +20,18 @@ public record ToolMetadata(
|
||||
JsonNode publicDefinition,
|
||||
boolean exactEndpoint) {
|
||||
|
||||
/**
|
||||
* 모든 생성 경로가 지나는 표준 생성자로, {@code inputSchema}가 문서 밖을 참조하지 않는지와 정규식이 빨리 끝나는지 확인합니다. Portal 매니페스트 파싱, local 파일 로딩, Redis snapshot
|
||||
* 역직렬화가 모두 여기를 지나므로 검사 지점이 하나로 모입니다. 위반 시 {@link IllegalStateException}을 던져 해당 Tool이 Registry에 올라가지 못하게 합니다.
|
||||
*/
|
||||
public ToolMetadata {
|
||||
ToolSchemaReferencePolicy.assertNoExternalReference(inputSchema);
|
||||
ToolSchemaPatternPolicy.assertPatternsTerminateQuickly(inputSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code exactEndpoint}를 쓰지 않는 호출자를 위해 기본값 {@code false}로 표준 생성자에 위임합니다.
|
||||
*/
|
||||
public ToolMetadata(
|
||||
String name,
|
||||
String version,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package io.shinhanlife.dap.biz.mcp.registry;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
/**
|
||||
* Tool의 {@code inputSchema}가 요청 스레드를 오래 붙잡는 정규식 검증을 유발하지 못하게 막는 정책입니다. {@link ToolMetadata}가 만들어질 때만 호출되므로 매니페스트·local 파일·Redis snapshot 중
|
||||
* 어느 경로로 들어온 schema든 같은 규칙을 통과하며, 요청 경로에는 비용을 더하지 않습니다.
|
||||
*
|
||||
* <p>MCP는 joni와 graal-js를 해석하지 않아 {@code pattern} 검증이 {@code java.util.regex}로 처리된다. 이 엔진은 백트래킹 기반이고 {@code Matcher.find()}로 모든 시작 위치를
|
||||
* 시도하므로, 특정 정규식과 긴 입력의 조합에서 처리 시간이 다항·지수적으로 늘어난다. 인증이 없는 경계(ADR-0006)라 호출 빈도를 줄여 주는 계층도 없다.
|
||||
*
|
||||
* <p>규칙은 측정에 근거하며 안전을 증명하지 않는다. 근거와 한계는
|
||||
* <a href="../../../../../../../../docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md">ADR-0012</a>에 있다.
|
||||
*/
|
||||
final class ToolSchemaPatternPolicy {
|
||||
|
||||
/**
|
||||
* 허용할 정규식 길이 상한입니다. 업무 schema가 이보다 긴 정규식을 쓰는 경우는 사실상 없습니다.
|
||||
*/
|
||||
private static final int MAX_PATTERN_LENGTH = 512;
|
||||
|
||||
/**
|
||||
* 정규식 하나가 가질 수 있는 무한 수량자({@code *}, {@code +}, {@code {n,}}) 개수 상한입니다. 겹치는 문자 집합에 이런 수량자가 k개 이어지면 비용이 입력 길이의 k제곱으로 늘어납니다. 측정상 4개부터는
|
||||
* 아래 입력 상한 안에서도 초 단위로 넘어가고, 정상 업무 정규식이 3개를 넘는 경우는 드뭅니다.
|
||||
*/
|
||||
private static final int MAX_UNBOUNDED_QUANTIFIERS = 3;
|
||||
|
||||
/**
|
||||
* {@code pattern}을 선언한 문자열 필드가 함께 선언해야 하는 {@code maxLength}의 상한입니다. 비용이 입력 길이에 달려 있으므로, 길이를 묶는 것이 정규식 모양을 검사하는 것보다 확실합니다.
|
||||
*/
|
||||
private static final int MAX_PATTERNED_STRING_LENGTH = 256;
|
||||
|
||||
private ToolSchemaPatternPolicy() {}
|
||||
|
||||
/**
|
||||
* schema 전체를 훑어 {@code pattern} 정규식과 그 필드의 길이 제한을 검사하고, 길이를 묶을 수 없는 {@code patternProperties}는 거부합니다. schema가 없으면 통과시키고, 위반을 찾으면 해당 Tool을
|
||||
* 등록하지 못하도록 {@link IllegalStateException}을 던집니다. 호출자는 이 예외를 기존 매니페스트 형식 오류와 같게 다루므로 bundle 단위 실패 격리와 Redis cache miss 동작이 그대로
|
||||
* 적용됩니다.
|
||||
*/
|
||||
static void assertPatternsTerminateQuickly(JsonNode schema) {
|
||||
if (schema == null || schema.isNull()) {
|
||||
return;
|
||||
}
|
||||
Deque<JsonNode> pending = new ArrayDeque<>();
|
||||
pending.push(schema);
|
||||
while (!pending.isEmpty()) {
|
||||
JsonNode node = pending.pop();
|
||||
if (node.isObject()) {
|
||||
assertKeywordPattern(node);
|
||||
assertPatternPropertiesAbsent(node);
|
||||
}
|
||||
node.forEach(pending::push);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 schema 객체의 {@code pattern}과 그 필드의 {@code maxLength}를 함께 검사합니다. 값이 문자열이 아니면 정규식이 아니라 {@code properties} 아래에 우연히 같은 이름을 쓴 필드
|
||||
* 정의이므로 건너뜁니다.
|
||||
*/
|
||||
private static void assertKeywordPattern(JsonNode node) {
|
||||
JsonNode pattern = node.get("pattern");
|
||||
if (pattern == null || !pattern.isTextual()) {
|
||||
return;
|
||||
}
|
||||
assertSafeRegex(pattern.asText());
|
||||
assertBoundedLength(node.get("maxLength"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규식이 걸린 문자열의 길이가 묶여 있는지 확인합니다. 검증 비용이 입력 길이를 따라 늘어나므로, 상한이 없으면 정규식 모양과 무관하게 요청 body 한도(약 1MB)까지 열려 버립니다.
|
||||
*/
|
||||
private static void assertBoundedLength(JsonNode maxLength) {
|
||||
if (maxLength == null || !maxLength.isIntegralNumber()) {
|
||||
throw new IllegalStateException("Tool inputSchema pattern requires maxLength on the same field");
|
||||
}
|
||||
if (maxLength.intValue() <= 0 || maxLength.intValue() > MAX_PATTERNED_STRING_LENGTH) {
|
||||
throw new IllegalStateException(
|
||||
"Tool inputSchema maxLength with pattern must be at most " + MAX_PATTERNED_STRING_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code patternProperties}를 아예 거부합니다. 이 keyword는 값이 아니라 <em>입력 객체의 key</em>에 정규식을 적용하는데, key 길이를 선언할 자리가 없어 {@code pattern}에 쓴 길이
|
||||
* 상한 방식을 그대로 적용할 수 없습니다. {@code propertyNames}로 길이를 묶는 방법은 keyword 평가 순서가 명세에 정해져 있지 않아 정규식이 먼저 돌 수 있으므로 통제로 쓰지 않습니다.
|
||||
*
|
||||
* <p>현재 어떤 Tool도 이 keyword를 쓰지 않으므로, 묶을 수 없는 것을 남겨 두는 대신 쓰지 않는 기능을 닫습니다. 실제 필요가 생기면 길이를 묶는 방법을 정한 새 ADR을 먼저 씁니다.
|
||||
*/
|
||||
private static void assertPatternPropertiesAbsent(JsonNode node) {
|
||||
if (node.has("patternProperties")) {
|
||||
throw new IllegalStateException("Tool inputSchema must not use patternProperties");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규식 하나가 길이 상한과 컴파일 가능성을 만족하고, 반복 구조가 허용 범위인지 확인합니다. 컴파일을 여기서 해 두면 잘못된 정규식이 요청 시점이 아니라 등록 시점에 걸립니다.
|
||||
*/
|
||||
private static void assertSafeRegex(String regex) {
|
||||
if (regex.length() > MAX_PATTERN_LENGTH) {
|
||||
throw new IllegalStateException(
|
||||
"Tool inputSchema pattern must be at most " + MAX_PATTERN_LENGTH + " characters");
|
||||
}
|
||||
try {
|
||||
Pattern.compile(regex);
|
||||
} catch (PatternSyntaxException exception) {
|
||||
throw new IllegalStateException("Tool inputSchema pattern is not a valid regular expression");
|
||||
}
|
||||
assertRepetitionIsBudgeted(regex);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 가지 반복 구조를 거부합니다.
|
||||
*
|
||||
* <ol>
|
||||
* <li>무한 수량자를 품은 그룹을 다시 반복하는 형태. {@code (x+x+)+y}와 {@code (.*,){11}P}가 여기 해당하며, 바깥 반복 횟수에 상한이 있어도 측정상 폭증했으므로 {@code {11}} 같은
|
||||
* 유한 반복도 함께 막습니다.
|
||||
* <li>무한 수량자가 {@value #MAX_UNBOUNDED_QUANTIFIERS}개를 넘는 형태. {@code a*a*a*a*a*b}처럼 겹치는 문자 집합에 수량자가 이어지는 경우를 줄입니다.
|
||||
* </ol>
|
||||
*
|
||||
* <p>겹침 여부까지 판정하지는 않으므로 이 검사만으로 안전이 보장되지 않습니다. 실질적인 상한은 함께 적용하는 {@code maxLength} 제한이 만듭니다.
|
||||
*/
|
||||
private static void assertRepetitionIsBudgeted(String regex) {
|
||||
// 각 원소는 "지금까지 이 그룹 안에서 무한 수량자를 봤는가"다.
|
||||
Deque<Boolean> openGroups = new ArrayDeque<>();
|
||||
boolean insideCharacterClass = false;
|
||||
int unboundedQuantifiers = 0;
|
||||
int index = 0;
|
||||
while (index < regex.length()) {
|
||||
char current = regex.charAt(index);
|
||||
if (current == '\\') {
|
||||
// 이스케이프된 문자는 수량자도 그룹도 아니다.
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (insideCharacterClass) {
|
||||
insideCharacterClass = current != ']';
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (current == '[') {
|
||||
insideCharacterClass = true;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (current == '(') {
|
||||
openGroups.push(Boolean.FALSE);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (current == ')') {
|
||||
boolean groupHasUnbounded = !openGroups.isEmpty() && openGroups.pop();
|
||||
int unbounded = unboundedQuantifierLength(regex, index + 1);
|
||||
int bounded = unbounded > 0 ? 0 : boundedQuantifierLength(regex, index + 1);
|
||||
if (groupHasUnbounded && (unbounded > 0 || bounded > 0)) {
|
||||
throw new IllegalStateException(
|
||||
"Tool inputSchema pattern must not repeat a group that already repeats without bound");
|
||||
}
|
||||
if (unbounded > 0) {
|
||||
unboundedQuantifiers++;
|
||||
markEnclosingGroup(openGroups);
|
||||
}
|
||||
index += 1 + unbounded + bounded;
|
||||
continue;
|
||||
}
|
||||
int unbounded = unboundedQuantifierLength(regex, index);
|
||||
if (unbounded > 0) {
|
||||
unboundedQuantifiers++;
|
||||
markEnclosingGroup(openGroups);
|
||||
index += unbounded;
|
||||
} else {
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (unboundedQuantifiers > MAX_UNBOUNDED_QUANTIFIERS) {
|
||||
throw new IllegalStateException(
|
||||
"Tool inputSchema pattern must use at most "
|
||||
+ MAX_UNBOUNDED_QUANTIFIERS
|
||||
+ " unbounded quantifiers");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 열려 있는 그룹에 무한 수량자를 봤다고 기록합니다. 그룹 밖이면 중첩 판정 대상이 없습니다.
|
||||
*/
|
||||
private static void markEnclosingGroup(Deque<Boolean> openGroups) {
|
||||
if (!openGroups.isEmpty()) {
|
||||
openGroups.pop();
|
||||
openGroups.push(Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 위치에서 시작하는 무한 수량자({@code *}, {@code +}, {@code {n,}})의 길이를 반환하고, 아니면 0을 반환합니다.
|
||||
*/
|
||||
private static int unboundedQuantifierLength(String regex, int start) {
|
||||
if (start >= regex.length()) {
|
||||
return 0;
|
||||
}
|
||||
char current = regex.charAt(start);
|
||||
if (current == '*' || current == '+') {
|
||||
return 1;
|
||||
}
|
||||
if (current != '{') {
|
||||
return 0;
|
||||
}
|
||||
int close = regex.indexOf('}', start);
|
||||
if (close < 0) {
|
||||
return 0;
|
||||
}
|
||||
return regex.substring(start + 1, close).endsWith(",") ? close - start + 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 위치에서 시작하는 상한 있는 수량자({@code ?}, {@code {n}}, {@code {n,m}})의 길이를 반환하고, 아니면 0을 반환합니다.
|
||||
*/
|
||||
private static int boundedQuantifierLength(String regex, int start) {
|
||||
if (start >= regex.length()) {
|
||||
return 0;
|
||||
}
|
||||
if (regex.charAt(start) == '?') {
|
||||
return 1;
|
||||
}
|
||||
if (regex.charAt(start) != '{') {
|
||||
return 0;
|
||||
}
|
||||
int close = regex.indexOf('}', start);
|
||||
return close < 0 ? 0 : close - start + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package io.shinhanlife.dap.biz.mcp.registry;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
|
||||
/**
|
||||
* Tool의 {@code inputSchema}가 문서 밖을 가리키는 참조를 담지 못하게 막는 정책입니다. {@link ToolMetadata}가 만들어질 때만 호출되므로 Portal 매니페스트, local 파일, Redis snapshot 중 어느 경로로 들어온
|
||||
* schema든 같은 규칙을 통과합니다. JSON Schema 검증기는 문서 밖 참조를 만나면 그 주소로 직접 조회를 시도하므로, 매니페스트가 서버의 outbound 호출 대상을 정하는 통로가 되지 않도록 수신 시점에 끊습니다.
|
||||
*/
|
||||
final class ToolSchemaReferencePolicy {
|
||||
|
||||
/**
|
||||
* MCP가 사용하는 유일한 JSON Schema dialect입니다. 다른 dialect를 선언하면 검증기가 그 meta-schema를 외부에서 조회할 수 있습니다.
|
||||
*/
|
||||
private static final String SUPPORTED_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
||||
|
||||
/**
|
||||
* 참조 대상을 문서 안으로 한정하는 keyword입니다. 값이 {@code #}으로 시작하면 같은 문서 안의 위치를 가리킨다.
|
||||
*/
|
||||
private static final String[] REFERENCE_KEYWORDS = {"$ref", "$dynamicRef"};
|
||||
|
||||
private ToolSchemaReferencePolicy() {}
|
||||
|
||||
/**
|
||||
* schema 전체를 훑어 문서 밖을 가리키는 참조가 있으면 거부합니다. schema가 없으면 검증할 것이 없으므로 그대로 통과시키고, 위반을 찾으면 해당 Tool을 등록하지 못하도록
|
||||
* {@link IllegalStateException}을 던집니다. 호출자는 이 예외를 기존 매니페스트 형식 오류와 같게 다루므로 bundle 단위 실패 격리와 Redis cache miss 동작이 그대로 적용됩니다.
|
||||
* 적대적으로 깊게 중첩된 schema에서도 스택이 무너지지 않도록 재귀 대신 명시적 스택으로 순회합니다.
|
||||
*/
|
||||
static void assertNoExternalReference(JsonNode schema) {
|
||||
if (schema == null || schema.isNull()) {
|
||||
return;
|
||||
}
|
||||
Deque<JsonNode> pending = new ArrayDeque<>();
|
||||
pending.push(schema);
|
||||
while (!pending.isEmpty()) {
|
||||
JsonNode node = pending.pop();
|
||||
if (node.isObject()) {
|
||||
assertReferencesStayInDocument(node);
|
||||
assertDialectIsSupported(node);
|
||||
}
|
||||
node.forEach(pending::push);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 schema 객체의 참조 keyword가 같은 문서 안을 가리키는지 확인합니다. 문자열이 아닌 값은 참조가 아니라 {@code properties} 아래의 필드 정의이므로 건너뜁니다.
|
||||
*/
|
||||
private static void assertReferencesStayInDocument(JsonNode node) {
|
||||
for (String keyword : REFERENCE_KEYWORDS) {
|
||||
JsonNode reference = node.get(keyword);
|
||||
if (reference != null && reference.isTextual() && !reference.asText().startsWith("#")) {
|
||||
throw new IllegalStateException(
|
||||
"Tool inputSchema " + keyword + " must stay inside the document");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* schema가 선언한 dialect가 MCP가 쓰는 2020-12인지 확인합니다. 선언이 없으면 검증기의 기본 dialect가 적용되므로 통과시킵니다.
|
||||
*/
|
||||
private static void assertDialectIsSupported(JsonNode node) {
|
||||
JsonNode dialect = node.get("$schema");
|
||||
if (dialect != null && dialect.isTextual() && !SUPPORTED_DIALECT.equals(dialect.asText())) {
|
||||
throw new IllegalStateException("Tool inputSchema $schema must be " + SUPPORTED_DIALECT);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user