Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,12 @@ db-reset: ## 重置数据库
validate-release-config: ## 校验发布环境变量文件(默认 .env.release)
./scripts/validate-release-config.sh .env.release

staging: ## 构建并启动 staging 环境,运行 smoke test(混合模式:后端镜像 + 前端静态文件
staging: ## 构建并启动 staging 环境,运行 smoke test(混合模式:后端镜像 + 前端镜像
@echo "=== [1/5] Building backend JAR and Docker image ==="
cd server && ./mvnw package -DskipTests -B -q
docker build -t $(STAGING_SERVER_IMAGE) -f server/Dockerfile.dev server
@echo "=== [2/5] Building frontend static files ==="
cd web && pnpm run build
@echo "=== [2/5] Building frontend Docker image ==="
docker build -t skillhub-web:staging -f web/Dockerfile web
@echo "=== [3/5] Starting dependency services ==="
$(STAGING_BASE_COMPOSE) up -d --wait
@echo "=== [4/5] Starting staging services ==="
Expand Down
3 changes: 2 additions & 1 deletion cli/src/shared/skill-name-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface ParsedSkillName {

export function parseSkillName(skillName: string, defaultNamespace = 'global'): ParsedSkillName {
const separatorIndex = skillName.indexOf('--')
const trailingSeparatorIndex = skillName.lastIndexOf('--')

if (separatorIndex <= 0) {
return {
Expand All @@ -13,7 +14,7 @@ export function parseSkillName(skillName: string, defaultNamespace = 'global'):
}
}

if (separatorIndex === skillName.length - 2) {
if (trailingSeparatorIndex === separatorIndex && trailingSeparatorIndex + 2 === skillName.length) {
return {
namespace: defaultNamespace,
slug: skillName.slice(0, -2)
Expand Down
8 changes: 8 additions & 0 deletions cli/test/unit/shared/skill-name-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ describe('parseSkillName', () => {
slug: 'slug--with--dashes'
})
})

test('should preserve namespace when namespaced slug ends with separator characters', () => {
const result = parseSkillName('namespace--slug--')
expect(result).toEqual({
namespace: 'namespace',
slug: 'slug--'
})
})
})

describe('with slug only format', () => {
Expand Down
12 changes: 6 additions & 6 deletions docker-compose.staging.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Staging environment: hybrid mode
# - Backend: locally built Docker image
# - Frontend: locally built static files mounted into Nginx
# - Frontend: locally built Docker image
# - Dependencies: reuses docker-compose.yml (postgres, redis, minio)
#
# Usage: make staging
Expand All @@ -23,7 +23,7 @@ services:
REDIS_PORT: 6379
SESSION_COOKIE_SECURE: "false"
SKILLHUB_PUBLIC_BASE_URL: "http://localhost"
DEVICE_AUTH_VERIFICATION_URI: "http://localhost/cli/auth"
DEVICE_AUTH_VERIFICATION_URI: "http://localhost/device"
SKILLHUB_STORAGE_PROVIDER: s3
STORAGE_BASE_PATH: /var/lib/skillhub/storage
SKILLHUB_STORAGE_S3_ENDPOINT: http://minio:9000
Expand Down Expand Up @@ -61,12 +61,12 @@ services:
start_period: 60s

web:
image: nginx:alpine
image: skillhub-web:staging
build:
context: ./web
dockerfile: Dockerfile
ports:
- "80:80"
volumes:
- ./web/dist:/usr/share/nginx/html:ro
- ./web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
environment:
SKILLHUB_API_UPSTREAM: http://server:8080
SKILLHUB_WEB_API_BASE_URL: ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;

/**
* Declares a dedicated stateless security chain for public compatibility endpoints used by
Expand All @@ -22,21 +23,22 @@ public class ClawHubRegistrySecurityConfig {
@Order(0)
public SecurityFilterChain publicLabelFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher(
new OrRequestMatcher(
new AntPathRequestMatcher("/api/v1/labels"),
new AntPathRequestMatcher("/api/web/labels")
)
)
.securityMatcher(publicLabelRequestMatcher())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.csrf(csrf -> csrf.disable())
.requestCache(cache -> cache.disable())
.securityContext(context -> context.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

return http.build();
}

static RequestMatcher publicLabelRequestMatcher() {
return new OrRequestMatcher(
new AntPathRequestMatcher("/api/v1/labels", "GET"),
new AntPathRequestMatcher("/api/web/labels", "GET")
);
}

@Bean
@Order(1)
public SecurityFilterChain clawHubRegistryFilterChain(
Expand Down
2 changes: 1 addition & 1 deletion server/skillhub-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ skillhub:
editable: false
requires-review: false
device-auth:
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth}
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/device}
security:
scanner:
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.iflytek.skillhub.compat;

import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.web.util.matcher.RequestMatcher;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ClawHubRegistrySecurityConfigTest {

@Test
void publicLabelMatcherOnlyMatchesGetRequests() {
RequestMatcher matcher = ClawHubRegistrySecurityConfig.publicLabelRequestMatcher();

assertTrue(matcher.matches(request("GET", "/api/v1/labels")));
assertTrue(matcher.matches(request("GET", "/api/web/labels")));
assertFalse(matcher.matches(request("POST", "/api/v1/labels")));
assertFalse(matcher.matches(request("POST", "/api/web/labels")));
}

private MockHttpServletRequest request(String method, String path) {
MockHttpServletRequest request = new MockHttpServletRequest(method, path);
request.setServletPath(path);
return request;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public class DeviceAuthService {

public DeviceAuthService(RedisTemplate<String, Object> redisTemplate,
ApiTokenService apiTokenService,
@Value("${skillhub.device-auth.verification-uri:/cli/auth}") String verificationUri) {
@Value("${skillhub.device-auth.verification-uri:/device}") String verificationUri) {
this.redisTemplate = redisTemplate;
this.apiTokenService = apiTokenService;
this.verificationUri = verificationUri;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.iflytek.skillhub.domain.skill.metadata;

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 java.util.LinkedHashMap;
import java.util.Map;
Expand All @@ -13,6 +15,8 @@
public class SkillMetadataParser {

private static final String FRONTMATTER_DELIMITER = "---";
private static final int FRONTMATTER_CODE_POINT_LIMIT = 64 * 1024;
private static final int FRONTMATTER_NESTING_DEPTH_LIMIT = 20;

public SkillMetadata parse(String content) {
if (content == null || content.isBlank()) {
Expand Down Expand Up @@ -58,8 +62,12 @@ public SkillMetadata parse(String content) {
}

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 = new Yaml();
Yaml yaml = newSafeYamlParser();
Object parsed = yaml.load(yamlContent);
if (!(parsed instanceof Map)) {
throw new DomainBadRequestException("error.skill.metadata.yaml.notMap");
Expand All @@ -70,6 +78,9 @@ private Map<String, Object> parseFrontmatter(String yamlContent) {
} catch (DomainBadRequestException exception) {
throw exception;
} catch (Exception exception) {
if (isLoaderConstraintException(exception)) {
throw new DomainBadRequestException("error.skill.metadata.yaml.invalid", exception.getMessage());
}
Map<String, Object> fallback = parseLooseFrontmatter(yamlContent);
if (!fallback.isEmpty()) {
return fallback;
Expand All @@ -78,6 +89,153 @@ private Map<String, Object> parseFrontmatter(String yamlContent) {
}
}

private Yaml newSafeYamlParser() {
LoaderOptions options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
options.setCodePointLimit(FRONTMATTER_CODE_POINT_LIMIT);
options.setNestingDepthLimit(FRONTMATTER_NESTING_DEPTH_LIMIT);
return new Yaml(new SafeConstructor(options));
}

private void validateFrontmatterCodePointLimit(String yamlContent) {
if (yamlContent.codePointCount(0, yamlContent.length()) > FRONTMATTER_CODE_POINT_LIMIT) {
throw new DomainBadRequestException(
"error.skill.metadata.yaml.invalid",
"Frontmatter exceeds the supported size"
);
}
}

private boolean isLoaderConstraintException(Exception exception) {
String message = exception.getMessage();
if (message == null) {
return false;
}
return message.contains("exceeds the limit")
|| message.contains("Nesting Depth exceeded")
|| 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")) {
Expand All @@ -96,6 +254,12 @@ private Map<String, Object> parseLooseFrontmatter(String yamlContent) {
if (key.isEmpty()) {
continue;
}
if (values.containsKey(key)) {
throw new DomainBadRequestException(
"error.skill.metadata.yaml.invalid",
"Duplicate YAML keys are not allowed"
);
}

values.put(key, stripWrappingQuotes(value));
}
Expand Down
Loading
Loading