fix(security): preserve YAML block scalar text

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-20 10:11:27 +08:00
parent 4ff546b23d
commit 9a141b7a2b
2 changed files with 35 additions and 123 deletions

View file

@ -4,7 +4,10 @@ import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import org.yaml.snakeyaml.events.CollectionStartEvent;
import org.yaml.snakeyaml.events.ScalarEvent;
import java.io.StringReader;
import java.util.LinkedHashMap;
import java.util.Map;
@ -63,11 +66,9 @@ public class SkillMetadataParser {
private Map<String, Object> parseFrontmatter(String yamlContent) {
validateFrontmatterCodePointLimit(yamlContent);
if (containsExplicitYamlTag(yamlContent)) {
throw new DomainBadRequestException("error.skill.metadata.yaml.invalid", "Explicit YAML tags are not allowed");
}
try {
Yaml yaml = newSafeYamlParser();
rejectExplicitYamlTags(yaml, yamlContent);
Object parsed = yaml.load(yamlContent);
if (!(parsed instanceof Map)) {
throw new DomainBadRequestException("error.skill.metadata.yaml.notMap");
@ -97,6 +98,20 @@ public class SkillMetadataParser {
return new Yaml(new SafeConstructor(options));
}
private void rejectExplicitYamlTags(Yaml yaml, String yamlContent) {
for (var event : yaml.parse(new StringReader(yamlContent))) {
boolean taggedScalar = event instanceof ScalarEvent scalarEvent && scalarEvent.getTag() != null;
boolean taggedCollection = event instanceof CollectionStartEvent collectionEvent
&& collectionEvent.getTag() != null;
if (taggedScalar || taggedCollection) {
throw new DomainBadRequestException(
"error.skill.metadata.yaml.invalid",
"Explicit YAML tags are not allowed"
);
}
}
}
private void validateFrontmatterCodePointLimit(String yamlContent) {
if (yamlContent.codePointCount(0, yamlContent.length()) > FRONTMATTER_CODE_POINT_LIMIT) {
throw new DomainBadRequestException(
@ -116,126 +131,6 @@ public class SkillMetadataParser {
|| message.contains("found duplicate key");
}
private boolean containsExplicitYamlTag(String yamlContent) {
return yamlContent.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.anyMatch(this::containsExplicitYamlTagInLine);
}
private boolean containsExplicitYamlTagInLine(String line) {
String candidate = stripLeadingIndicatorsAndAnchors(line);
if (startsWithExplicitYamlTag(candidate)) {
return true;
}
if (candidate.startsWith("[") || candidate.startsWith("{")) {
return containsFlowExplicitYamlTag(candidate);
}
int separatorIndex = line.indexOf(':');
if (separatorIndex <= 0) {
return false;
}
String value = stripLeadingAnchors(line.substring(separatorIndex + 1));
if (startsWithExplicitYamlTag(value)) {
return true;
}
return (value.startsWith("[") || value.startsWith("{")) && containsFlowExplicitYamlTag(value);
}
private String stripLeadingIndicatorsAndAnchors(String line) {
String candidate = line.trim();
boolean advanced;
do {
advanced = false;
if (candidate.startsWith("- ") || candidate.startsWith("? ") || candidate.startsWith(": ")) {
candidate = candidate.substring(1).trim();
advanced = true;
continue;
}
String withoutAnchor = stripLeadingAnchors(candidate);
if (!withoutAnchor.equals(candidate)) {
candidate = withoutAnchor;
advanced = true;
}
} while (advanced);
return candidate;
}
private String stripLeadingAnchors(String value) {
String candidate = value.trim();
while (candidate.startsWith("&") && candidate.length() > 1) {
int end = 1;
while (end < candidate.length()
&& !Character.isWhitespace(candidate.charAt(end))
&& !isYamlFlowDelimiter(candidate.charAt(end))) {
end++;
}
if (end == 1) {
break;
}
candidate = candidate.substring(end).trim();
}
return candidate;
}
private boolean startsWithExplicitYamlTag(String value) {
return value.startsWith("!") && value.length() > 1 && !Character.isWhitespace(value.charAt(1));
}
private boolean containsFlowExplicitYamlTag(String value) {
int flowDepth = 0;
char quote = '\0';
for (int i = 0; i < value.length(); i++) {
char current = value.charAt(i);
if (quote != '\0') {
if (current == quote && !isEscapedDoubleQuote(value, i, quote)) {
quote = '\0';
}
continue;
}
if (current == '\'' || current == '"') {
quote = current;
continue;
}
if (current == '[' || current == '{') {
flowDepth++;
if (startsWithExplicitYamlTag(stripLeadingAnchors(value.substring(i + 1)))) {
return true;
}
continue;
}
if (current == ']' || current == '}') {
flowDepth = Math.max(0, flowDepth - 1);
continue;
}
if (flowDepth > 0
&& (current == ',' || current == ':')
&& startsWithExplicitYamlTag(stripLeadingAnchors(value.substring(i + 1)))) {
return true;
}
}
return false;
}
private boolean isEscapedDoubleQuote(String value, int index, char quote) {
if (quote != '"' || index == 0) {
return false;
}
int backslashCount = 0;
for (int i = index - 1; i >= 0 && value.charAt(i) == '\\'; i--) {
backslashCount++;
}
return backslashCount % 2 == 1;
}
private boolean isYamlFlowDelimiter(char current) {
return current == '[' || current == ']' || current == '{' || current == '}' || current == ',';
}
private Map<String, Object> parseLooseFrontmatter(String yamlContent) {
Map<String, Object> values = new LinkedHashMap<>();
for (String rawLine : yamlContent.split("\\R")) {

View file

@ -343,6 +343,23 @@ class SkillMetadataParserTest {
assertEquals("Avoid CSS, !important when possible", metadata.description());
}
@Test
void allowsExclamationTextInsideBlockScalars() {
String content = """
---
name: css-guidance
description: |-
!important is literal prose, not a YAML tag
version: 1.0.0
---
Body
""";
SkillMetadata metadata = parser.parse(content);
assertEquals("!important is literal prose, not a YAML tag", metadata.description());
}
@Test
void testAllowsColonInDescriptionWithoutStrictYamlQuoting() {
String content = """