From 83ff64d76a86fbf2756087b588805a681b59c23f Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:52:15 +0800 Subject: [PATCH] fix(suite): validate portable version tokens Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_zh.properties | 1 + .../domain/suite/SkillSuiteDraftService.java | 6 ++++ .../suite/SkillSuiteDraftServiceTest.java | 24 +++++++++++++ web/src/pages/suite-detail.test.tsx | 13 +++++++ web/src/pages/suite-detail.tsx | 34 +++++++++++-------- 6 files changed, 65 insertions(+), 14 deletions(-) diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index bb038dfb..6601d92c 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -225,6 +225,7 @@ error.suite.publish.notPrivate=Only private Skill Suites can be published withou error.suite.publish.notDraft=Skill Suite version {0} is not a draft error.suite.displayName.required=Skill Suite display name is required error.suite.version.required=Skill Suite version is required +error.suite.version.invalid=Skill Suite version must use 1-64 portable characters: letters, numbers, dot, underscore, plus, or hyphen error.suite.slug.exists=A Skill Suite with slug {0} already exists in this namespace error.suite.access.denied=You do not have permission to view this Skill Suite error.suite.install.unavailable=The Skill Suite cannot be installed because one or more members are unavailable diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index add78f54..e43d42bf 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -225,6 +225,7 @@ error.suite.publish.notPrivate=只有私有技能套件可以免审核发布 error.suite.publish.notDraft=技能套件版本 {0} 不是草稿 error.suite.displayName.required=技能套件显示名称不能为空 error.suite.version.required=技能套件版本不能为空 +error.suite.version.invalid=技能套件版本须为 1-64 位,且只能包含字母、数字、点、下划线、加号或连字符 error.suite.slug.exists=当前命名空间已存在 slug 为 {0} 的技能套件 error.suite.access.denied=无权查看该技能套件 error.suite.install.unavailable=技能套件中存在当前不可用的成员,无法安装 diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java index c6a6587f..d76eb04f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java @@ -17,12 +17,15 @@ import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; +import java.util.regex.Pattern; /** Creates Suite drafts after member coordinates have been resolved to exact Skill versions. */ @Service public class SkillSuiteDraftService { private static final Logger log = LoggerFactory.getLogger(SkillSuiteDraftService.class); + private static final Pattern PORTABLE_VERSION_PATTERN = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._+-]{0,63}"); private final SkillSuiteRepository suiteRepository; private final SkillSuiteVersionRepository versionRepository; @@ -202,6 +205,9 @@ public class SkillSuiteDraftService { if (command.version() == null || command.version().isBlank()) { throw new DomainBadRequestException("error.suite.version.required"); } + if (!PORTABLE_VERSION_PATTERN.matcher(command.version()).matches()) { + throw new DomainBadRequestException("error.suite.version.invalid"); + } SkillSuiteCompositionPolicy.validate(command.members(), command.entrySkillVersionId()); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java index 73a7ff5f..579d0277 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.SkillVisibility; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -17,6 +18,7 @@ import java.util.Optional; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -126,6 +128,28 @@ class SkillSuiteDraftServiceTest { verify(publicationValidator).validate(suite, version); } + @Test + void rejectsSuiteVersionsThatCannotBeUsedSafelyAcrossCliShells() { + Namespace namespace = new Namespace("team", "Team", "owner"); + setId(namespace, 1L); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace)); + SkillSuiteActionContext context = new SkillSuiteActionContext( + "author", Map.of(1L, NamespaceRole.MEMBER), Set.of(), + "request-3", "127.0.0.1", "test"); + + for (String version : List.of("1.0.0; touch pwned", "a".repeat(65))) { + CreateSkillSuiteDraftCommand command = new CreateSkillSuiteDraftCommand( + 1L, "writers", "Writers", "Summary", null, version, + SkillVisibility.PUBLIC, null, 40L, + List.of(new SkillSuiteMemberSelection( + 30L, 40L, "global", "writer", "1.0.0", "sha256:abc"))); + + assertThatThrownBy(() -> service.create(command, context)) + .isInstanceOf(DomainBadRequestException.class) + .hasMessage("error.suite.version.invalid"); + } + } + private void setId(Object target, Long id) { try { var field = target.getClass().getDeclaredField("id"); diff --git a/web/src/pages/suite-detail.test.tsx b/web/src/pages/suite-detail.test.tsx index c277eef5..4bf9795c 100644 --- a/web/src/pages/suite-detail.test.tsx +++ b/web/src/pages/suite-detail.test.tsx @@ -189,4 +189,17 @@ describe('SuiteDetailPage', () => { )).not.toBeNull() expect(within(sidebar).getByLabelText('suite.copyInstallCommand')).not.toBeNull() }) + + it('does not expose a copyable shell command for an unsafe legacy version', () => { + const unsafeSuite = suite() + unsafeSuite.version = '1.0.0; touch pwned' + mocks.detail = { data: unsafeSuite, isLoading: false, error: null } + + render() + + const sidebar = screen.getByRole('complementary', { name: 'suite.detailsSidebar' }) + expect(within(sidebar).getByRole('alert').textContent) + .toBe('skillDetail.installCommandUnsafeVersion') + expect(within(sidebar).queryByLabelText('suite.copyInstallCommand')).toBeNull() + }) }) diff --git a/web/src/pages/suite-detail.tsx b/web/src/pages/suite-detail.tsx index 45247c13..2d841671 100644 --- a/web/src/pages/suite-detail.tsx +++ b/web/src/pages/suite-detail.tsx @@ -6,7 +6,7 @@ import { useSuiteDetail, useSuiteVersions, useSubmitSuite } from '@/shared/hooks import { suiteBlockingReasonLabel, suiteStatusLabel, suiteVisibilityLabel } from '@/features/suite/suite-labels' import { SuiteManagementActions } from '@/features/suite/suite-management-actions' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' -import { getBaseUrl } from '@/features/skill/install-command' +import { getBaseUrl, isPortableSkillVersion } from '@/features/skill/install-command' import { Card } from '@/shared/ui/card' import { Button, buttonVariants } from '@/shared/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' @@ -26,7 +26,7 @@ export function SuiteDetailPage() { const submitMutation = useSubmitSuite() const registryUrl = useMemo(() => getBaseUrl(), []) const command = useMemo( - () => suite + () => suite && isPortableSkillVersion(suite.version) ? `skillhub suite install @${suite.namespace}/${suite.slug} --version ${suite.version} --registry ${registryUrl}` : '', [registryUrl, suite], @@ -272,18 +272,24 @@ export function SuiteDetailPage() {