fix(suite): validate portable version tokens

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-09 20:52:15 +08:00
parent d9696be9e4
commit 83ff64d76a
6 changed files with 65 additions and 14 deletions

View file

@ -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

View file

@ -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=技能套件中存在当前不可用的成员,无法安装

View file

@ -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());
}

View file

@ -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");

View file

@ -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(<SuiteDetailPage />)
const sidebar = screen.getByRole('complementary', { name: 'suite.detailsSidebar' })
expect(within(sidebar).getByRole('alert').textContent)
.toBe('skillDetail.installCommandUnsafeVersion')
expect(within(sidebar).queryByLabelText('suite.copyInstallCommand')).toBeNull()
})
})

View file

@ -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() {
<Terminal className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-semibold font-heading text-foreground">{t('suite.installCommand')}</span>
</div>
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-secondary p-3">
<code className="min-w-0 flex-1 overflow-x-auto text-sm">{command}</code>
<Button
variant="outline"
size="sm"
aria-label={t('suite.copyInstallCommand')}
onClick={async () => {
await navigator.clipboard.writeText(command)
toast.success(t('suite.commandCopied'))
}}
><Copy className="h-4 w-4" /></Button>
</div>
{command ? (
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-secondary p-3">
<code className="min-w-0 flex-1 overflow-x-auto text-sm">{command}</code>
<Button
variant="outline"
size="sm"
aria-label={t('suite.copyInstallCommand')}
onClick={async () => {
await navigator.clipboard.writeText(command)
toast.success(t('suite.commandCopied'))
}}
><Copy className="h-4 w-4" /></Button>
</div>
) : (
<p role="alert" className="text-sm text-destructive">
{t('skillDetail.installCommandUnsafeVersion')}
</p>
)}
</Card>
{versions?.length ? (