diff --git a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java index c2f9351ab..5abbfd514 100644 --- a/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java +++ b/server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java @@ -1069,7 +1069,7 @@ public ExtensionJson toExtensionVersionJson( } json.setVersionAlias(versionAlias); - json.setVerified(isVerified(extVersion)); + json.setVerified(repositories.isVerifiedPublisher(extVersion)); json.setReviewCount(Optional.ofNullable(extension.getReviewCount()).orElse(0L)); var serverUrl = UrlUtil.getBaseUrl(); json.setNamespaceUrl(createApiUrl(serverUrl, "api", json.getNamespace())); @@ -1339,20 +1339,6 @@ private ExtensionReplacementJson toReplacementJson( return json; } - private boolean isVerified(ExtensionVersion extVersion) { - if (extVersion.getPublishedWith() == null) { - return false; - } - - var user = extVersion.getPublishedWith().getUser(); - if (UserData.Role.PRIVILEGED.equals(user.getRole())) { - return true; - } - - var namespace = extVersion.getExtension().getNamespace(); - return repositories.isVerified(namespace, user); - } - private boolean isVerified( ExtensionVersion extVersion, Map> membershipsByNamespaceId @@ -1362,7 +1348,7 @@ private boolean isVerified( } var user = extVersion.getPublishedWith().getUser(); - if (UserData.Role.PRIVILEGED.equals(user.getRole())) { + if (user.isPrivileged()) { return true; } diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 76b478692..f73423122 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -36,6 +36,7 @@ import org.eclipse.openvsx.accesstoken.AccessTokenService; import org.eclipse.openvsx.eclipse.EclipseService; +import org.eclipse.openvsx.entities.ExtensionScan; import org.eclipse.openvsx.entities.ExtensionVersion; import org.eclipse.openvsx.entities.NamespaceMembership; import org.eclipse.openvsx.entities.PersonalAccessTokenType; @@ -57,6 +58,7 @@ import org.eclipse.openvsx.json.UserJson; import org.eclipse.openvsx.repositories.ExtensionScanRepository; import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.scanning.NamespaceOwnershipCheckScanner; import org.eclipse.openvsx.security.CodedAuthException; import org.eclipse.openvsx.settings.MutatingOperation; import org.eclipse.openvsx.storage.StorageUtilService; @@ -274,6 +276,7 @@ public List getOwnExtensions() { json.setPreview(latest.isPreview()); json.setActive(latest.getExtension().isActive()); json.setRemoved(latest.isExtensionRemoved()); + json.setVerified(repositories.isVerifiedPublisher(latest)); json.setFiles(fileUrls.get(latest.getId())); // Add scan/review status information @@ -284,6 +287,33 @@ public List getOwnExtensions() { .toList(); } + /** + * Looks up the most recent scan recorded for {@code extVersion}, or {@code null} if none exists + * (scanning disabled, or the version predates the scanning feature). + */ + private ExtensionScan findLatestScan(ExtensionVersion extVersion) { + var ext = extVersion.getExtension(); + return scanRepository + .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( + ext.getNamespace().getName(), + ext.getName(), + extVersion.getVersion(), + extVersion.getTargetPlatform()); + } + + /** + * Whether {@code scanResult} recorded a threat from the {@link NamespaceOwnershipCheckScanner}: + * the version's namespace already exists in a referenced external gallery and needs to be verified + * (claimed) before the version can be activated here. + */ + private boolean hasNamespaceOwnershipConflict(ExtensionScan scanResult) { + return scanResult != null + && repositories.findExtensionThreats(scanResult, NamespaceOwnershipCheckScanner.TYPE) + .stream() + .findAny() + .isPresent(); + } + /** * Add review/scan status information to the extension JSON. *

@@ -295,14 +325,8 @@ public List getOwnExtensions() { * */ private void enrichWithReviewStatus(ExtensionJson json, ExtensionVersion extVersion) { - // Look up scan by extension metadata (namespace, name, version, platform) - var ext = extVersion.getExtension(); - var scanResult = scanRepository - .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( - ext.getNamespace().getName(), - ext.getName(), - extVersion.getVersion(), - extVersion.getTargetPlatform()); + var scanResult = findLatestScan(extVersion); + json.setNamespaceOwnershipConflict(hasNamespaceOwnershipConflict(scanResult)); if (Boolean.TRUE.equals(json.getActive())) { // Only mark published if scan result indicates PASSED or no scan result exists (scanning disabled / manual @@ -440,6 +464,7 @@ public ResponseEntity getOwnExtension( json.setAllTargetPlatformVersions(users.getVersionsWithDeletePermission(user, extension, isOwner)); json.setActive(extension.isActive()); json.setRemoved(latest.isExtensionRemoved()); + json.setNamespaceOwnershipConflict(hasNamespaceOwnershipConflict(findLatestScan(latest))); } else { var error = "Extension not found: " + NamingUtil.toExtensionId(namespaceName, extensionName); throw new ErrorResultException(error, HttpStatus.NOT_FOUND); diff --git a/server/src/main/java/org/eclipse/openvsx/entities/UserData.java b/server/src/main/java/org/eclipse/openvsx/entities/UserData.java index ebf1093eb..17fe9b229 100644 --- a/server/src/main/java/org/eclipse/openvsx/entities/UserData.java +++ b/server/src/main/java/org/eclipse/openvsx/entities/UserData.java @@ -109,6 +109,14 @@ public Role getRole() { return role; } + /** + * Whether this user bypasses per-namespace verification entirely, e.g. in + * {@link org.eclipse.openvsx.repositories.RepositoryService#isVerifiedPublisher}. + */ + public boolean isPrivileged() { + return Role.PRIVILEGED.equals(role); + } + public @Nullable String getRoleAsString() { return Optional.ofNullable(this.getRole()).map(Role::toString).orElse(null); } diff --git a/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java b/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java index 10d7b73fa..3bb3ae4df 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/ExtensionJson.java @@ -111,6 +111,14 @@ public static ExtensionJson error(String message) { @NotNull private Boolean verified; + /** + * True when this version's latest scan recorded a namespace-ownership-check threat, i.e. the + * namespace it was published to already exists in a referenced external gallery and is not + * verified. Internal signal for the webui, not part of the documented public API. + */ + @Schema(hidden = true) + private Boolean namespaceOwnershipConflict; + /** * @deprecated */ @@ -347,6 +355,14 @@ public void setVerified(Boolean verified) { this.verified = verified; } + public Boolean getNamespaceOwnershipConflict() { + return namespaceOwnershipConflict; + } + + public void setNamespaceOwnershipConflict(Boolean namespaceOwnershipConflict) { + this.namespaceOwnershipConflict = namespaceOwnershipConflict; + } + public Map getAllVersions() { return allVersions; } @@ -642,6 +658,7 @@ public boolean equals(Object o) { && Objects.equals(reviewStatus, that.reviewStatus) && Objects.equals(reviewMessage, that.reviewMessage) && Objects.equals(verified, that.verified) + && Objects.equals(namespaceOwnershipConflict, that.namespaceOwnershipConflict) && Objects.equals(allVersions, that.allVersions) && Objects.equals(allVersionsUrl, that.allVersionsUrl) && Objects.equals(averageRating, that.averageRating) @@ -692,6 +709,7 @@ public int hashCode() { reviewStatus, reviewMessage, verified, + namespaceOwnershipConflict, allVersions, allVersionsUrl, averageRating, diff --git a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java index 36ac7b3a0..fa35939e1 100644 --- a/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java +++ b/server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java @@ -498,6 +498,28 @@ public boolean isVerified(Namespace namespace, UserData user) { return membershipJooqRepo.isVerified(namespace, user); } + /** + * Whether {@code user} counts as a verified publisher for {@code namespace}: a privileged user + * bypasses per-namespace verification entirely; otherwise this defers to + * {@link #isVerified(Namespace, UserData)} (member of a namespace with at least one owner). + */ + public boolean isVerifiedPublisher(Namespace namespace, UserData user) { + return user.isPrivileged() || isVerified(namespace, user); + } + + /** + * Whether the version's publisher counts as verified, per {@link #isVerifiedPublisher(Namespace, UserData)}. + * {@code false} when the version records no publisher. + */ + public boolean isVerifiedPublisher(ExtensionVersion extVersion) { + var publishedWith = extVersion.getPublishedWith(); + if (publishedWith == null) { + return false; + } + + return isVerifiedPublisher(extVersion.getExtension().getNamespace(), publishedWith.getUser()); + } + public Streamable findMemberships(Namespace namespace, String role) { return membershipRepo.findByNamespaceAndRoleIgnoreCase(namespace, role); } diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckConfig.java b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckConfig.java new file mode 100644 index 000000000..f69ef5884 --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckConfig.java @@ -0,0 +1,120 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for {@link NamespaceOwnershipCheckScanner}, extracted, to simplify testing. + */ +@Configuration +public class NamespaceOwnershipCheckConfig { + /** + * Is the namespace ownership check for extensions existing in the referenced gallery enabled or not. + *

+ * Property: {@code ovsx.scanning.namespace-ownership-check.enabled} + * Default: {@code false} + */ + @Value("${ovsx.scanning.namespace-ownership-check.enabled:false}") + private boolean enabled; + + /** + * Is the namespace ownership check required or not. + *

+ * Property: {@code ovsx.scanning.namespace-ownership-check.required} + * Default: {@code true} + */ + @Value("${ovsx.scanning.namespace-ownership-check.required:true}") + private boolean required; + + /** + * Is the namespace ownership check enforced or not. + *

+ * Property: {@code ovsx.scanning.namespace-ownership-check.enforced} + * Default: {@code true} + */ + @Value("${ovsx.scanning.namespace-ownership-check.enforced:true}") + private boolean enforced; + + /** + * Is the namespace ownership check needed to run on already existing and active extensions or not. + *

+ * Property: {@code ovsx.scanning.namespace-ownership-check.check-active-extensions} + * Default: {@code false} + */ + @Value("${ovsx.scanning.namespace-ownership-check.check-active-extensions:false}") + private boolean checkActiveExtensions; + + /** + * The referenced gallery API URL to perform the namespace ownership checks against. + *

+ * Property: {@code ovsx.scanning.namespace-ownership-check.gallery-url} + * Default: {@code ""} + */ + @Value("${ovsx.scanning.namespace-ownership-check.gallery-url:}") + private String galleryUrl; + + /** + * Default constructor. + */ + public NamespaceOwnershipCheckConfig() { + } + + /** + * For testing. + */ + public NamespaceOwnershipCheckConfig( + boolean enabled, + boolean required, + boolean enforced, + boolean checkActiveExtensions, + String galleryUrl + ) { + this.enabled = enabled; + this.required = required; + this.enforced = enforced; + this.checkActiveExtensions = checkActiveExtensions; + this.galleryUrl = galleryUrl; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isRequired() { + return required; + } + + public boolean isEnforced() { + return enforced; + } + + public boolean isCheckActiveExtensions() { + return checkActiveExtensions; + } + + public String getGalleryUrl() { + return galleryUrl; + } + + @PostConstruct + public void validate() { + if (enabled) { + if (galleryUrl == null || galleryUrl.isEmpty()) { + throw new IllegalStateException("ovsx.scanning.namespace-ownership-check.gallery-url must be set"); + } + } + } +} diff --git a/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java new file mode 100644 index 000000000..65179060c --- /dev/null +++ b/server/src/main/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScanner.java @@ -0,0 +1,204 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import java.util.List; +import java.util.Objects; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import org.jspecify.annotations.NonNull; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import org.eclipse.openvsx.adapter.ExtensionQueryParam; +import org.eclipse.openvsx.adapter.ExtensionQueryResult; +import org.eclipse.openvsx.adapter.IVSCodeService; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.repositories.RepositoryService; +import org.eclipse.openvsx.util.NamingUtil; +import org.eclipse.openvsx.util.UrlUtil; + +/** + * Scanner that guards against namespace-squatting: it blocks publishing into a namespace that is + * already claimed by some publisher in a referenced external gallery (by default the upstream VS + * Code Marketplace), unless the publishing namespace is verified (has an owner, not only + * contributors). The check is namespace-wide - it doesn't matter which extension(s) the upstream + * publisher offers, only that the namespace name itself is already taken there. + */ +@Component +public class NamespaceOwnershipCheckScanner implements Scanner { + + public static final String TYPE = "NAMESPACE_OWNERSHIP_CHECK"; + + /** + * The VS Code Gallery API has no dedicated "publisher exists" filter, so an existence check has to + * search by namespace name as free text and inspect the results for an exact publisher match. A + * generous page size keeps that match from being missed when the namespace name also turns up + * loosely-related, more "relevant" extensions from other publishers. + */ + private static final int NAMESPACE_SEARCH_PAGE_SIZE = 100; + + private final NamespaceOwnershipCheckConfig config; + private final RestTemplate restTemplate; + private final RepositoryService repositories; + private final EntityManager entityManager; + private final ScannerRegistry scannerRegistry; + + public NamespaceOwnershipCheckScanner( + NamespaceOwnershipCheckConfig config, + RestTemplate restTemplate, + RepositoryService repositories, + EntityManager entityManager, + ScannerRegistry scannerRegistry + ) { + this.config = config; + this.restTemplate = restTemplate; + this.repositories = repositories; + this.entityManager = entityManager; + this.scannerRegistry = scannerRegistry; + } + + @PostConstruct + void register() { + if (config.isEnabled()) { + scannerRegistry.registerScanner(this); + } + } + + @Override + @NonNull + public String getScannerType() { + return TYPE; + } + + @Override + public boolean isRequired() { + return config.isRequired(); + } + + @Override + public boolean enforcesThreats() { + return config.isEnforced(); + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public Scanner.@NonNull Invocation startScan(@NonNull Command command) throws ScannerException { + var extVersion = entityManager.find(ExtensionVersion.class, command.extensionVersionId()); + if (extVersion == null) { + throw new ScannerException("ExtensionVersion not found: " + command.extensionVersionId()); + } + + var extension = extVersion.getExtension(); + var namespace = extension.getNamespace(); + + Extension active = repositories.findActiveExtension(extension.getName(), namespace.getName()); + if (!config.isCheckActiveExtensions() && active != null) { + return new Scanner.Invocation.Completed( + Scanner.Result.clean( + "Extension '" + NamingUtil.toExtensionId(extension) + "' is already active.")); + } + + try { + boolean namespaceExists = namespaceExistsInReferencedGallery(namespace.getName()); + if (!namespaceExists) { + return new Scanner.Invocation.Completed(Scanner.Result.clean()); + } + } catch (RestClientException ex) { + if (config.isEnforced()) { + throw new ScannerException("Failed to perform " + TYPE, ex); + } else { + return new Scanner.Invocation.Completed( + Scanner.Result.clean( + "Failed to perform " + TYPE + " scan: " + ex.getMessage())); + } + } + + var publishedWith = extVersion.getPublishedWith(); + var user = publishedWith != null ? publishedWith.getUser() : null; + if (user != null && repositories.isVerifiedPublisher(namespace, user)) { + return new Scanner.Invocation.Completed( + Scanner.Result.clean( + "Namespace '" + namespace.getName() + + "' exists in the referenced gallery and is verified.")); + } + + var threat = new Scanner.Threat( + TYPE + "-conflict", + "Namespace '" + namespace.getName() + "' exists in the referenced gallery, " + + "but is not verified.", + "high"); + return new Scanner.Invocation.Completed(Scanner.Result.withThreats(List.of(threat))); + } + + /** + * Checks whether {@code namespaceName} is already claimed by some publisher in the referenced + * gallery, regardless of which extension(s) that publisher offers there - the goal is to catch + * namespace-squatting, not to match a specific extension identifier. + *

+ * The VS Code Gallery API has no filter for "publisher exists", so this searches by the namespace + * name as free text and then checks the results for an extension whose publisher name matches + * {@code namespaceName} exactly (case-insensitively, as VS Code Marketplace publisher ids are + * themselves case-insensitive) - a hit that merely mentions the name elsewhere doesn't count. + *

+ * Returns {@code true} or {@code false} only if the check was performed and the result was clear + * about it. In any other case this method throws. + */ + private boolean namespaceExistsInReferencedGallery(String namespaceName) throws RestClientException { + var requestUrl = UrlUtil.createApiUrl(config.getGalleryUrl(), "extensionquery"); + var requestData = new ExtensionQueryParam( + List.of( + new ExtensionQueryParam.Filter( + List.of( + new ExtensionQueryParam.Criterion( + ExtensionQueryParam.Criterion.FILTER_TARGET, + "Microsoft.VisualStudio.Code"), + new ExtensionQueryParam.Criterion( + ExtensionQueryParam.Criterion.FILTER_SEARCH_TEXT, + namespaceName)), + 1, + NAMESPACE_SEARCH_PAGE_SIZE, + 0, + 0)), + 0); + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set(HttpHeaders.ACCEPT, "application/json;api-version=" + IVSCodeService.GALLERY_API_VERSION); + var result = restTemplate + .postForObject(requestUrl, new HttpEntity<>(requestData, headers), ExtensionQueryResult.class); + if (result == null || result.results() == null || result.results().isEmpty()) { + return false; + } + + var extensions = result.results().getFirst().extensions(); + if (extensions == null) { + return false; + } + + return extensions.stream() + .map(ExtensionQueryResult.Extension::publisher) + .filter(Objects::nonNull) + .map(ExtensionQueryResult.Publisher::publisherName) + .anyMatch(namespaceName::equalsIgnoreCase); + } +} diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index 274cf2df8..b1a0139e3 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -3393,7 +3393,7 @@ private void mockForPublish(String mode) { .thenReturn(true); Mockito.when(repositories.canPublishInNamespace(token.getUser(), namespace)) .thenReturn(true); - Mockito.when(repositories.isVerified(namespace, token.getUser())) + Mockito.when(repositories.isVerifiedPublisher(any(ExtensionVersion.class))) .thenReturn(true); // Mock findMemberships(user) for similarity check Mockito.when(repositories.findMemberships(token.getUser())) @@ -3401,7 +3401,7 @@ private void mockForPublish(String mode) { } else if (mode.equals("contributor") || mode.equals("sole-contributor") || mode.equals("existing")) { Mockito.when(repositories.canPublishInNamespace(token.getUser(), namespace)) .thenReturn(true); - Mockito.when(repositories.isVerified(namespace, token.getUser())) + Mockito.when(repositories.isVerifiedPublisher(any(ExtensionVersion.class))) .thenReturn(true); if (mode.equals("contributor")) { var otherUser = new UserData(); @@ -3412,7 +3412,7 @@ private void mockForPublish(String mode) { ownerMem.setRole(NamespaceMembership.ROLE_OWNER); Mockito.when(repositories.findMemberships(namespace, NamespaceMembership.ROLE_OWNER)) .thenReturn(Streamable.of(ownerMem)); - Mockito.when(repositories.isVerified(namespace, token.getUser())) + Mockito.when(repositories.isVerifiedPublisher(any(ExtensionVersion.class))) .thenReturn(true); // Mock findMemberships(user) for similarity check - user is a contributor var contributorMem = new NamespaceMembership(); @@ -3424,7 +3424,7 @@ private void mockForPublish(String mode) { } else { Mockito.when(repositories.findMemberships(namespace, NamespaceMembership.ROLE_OWNER)) .thenReturn(Streamable.empty()); - Mockito.when(repositories.isVerified(namespace, token.getUser())) + Mockito.when(repositories.isVerifiedPublisher(any(ExtensionVersion.class))) .thenReturn(false); // Mock findMemberships(user) for similarity check - user might be sole contributor var contributorMem = new NamespaceMembership(); @@ -3447,6 +3447,10 @@ private void mockForPublish(String mode) { .thenReturn(true); if (mode.equals("privileged")) { token.getUser().setRole(UserData.Role.PRIVILEGED); + // A privileged user bypasses per-namespace verification (RepositoryService.isVerifiedPublisher), + // regardless of namespace membership. + Mockito.when(repositories.isVerifiedPublisher(any(ExtensionVersion.class))) + .thenReturn(true); // Mock findMemberships(user) for similarity check - privileged user might have memberships Mockito.when(repositories.findMemberships(token.getUser())) .thenReturn(Streamable.empty()); diff --git a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java index 064440790..c552e0f69 100644 --- a/server/src/test/java/org/eclipse/openvsx/UserAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/UserAPITest.java @@ -53,6 +53,7 @@ import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.scanning.ExtensionScanPersistenceService; import org.eclipse.openvsx.scanning.ExtensionScanService; +import org.eclipse.openvsx.scanning.NamespaceOwnershipCheckScanner; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.search.SimilarityCheckService; import org.eclipse.openvsx.search.SimilarityConfig; @@ -335,6 +336,60 @@ void testOwnExtensionNotLoggedIn() throws Exception { .andExpect(status().isForbidden()); } + @Test + void testOwnExtensionsVerifiedAndNamespaceOwnershipConflict() throws Exception { + // An extension kept inactive because its namespace conflicts with one already claimed in a + // referenced external gallery: 'verified' is false (no owner yet) and + // 'namespaceOwnershipConflict' reports the specific reason, so the webui can point the user at + // verifying/claiming the namespace instead of a generic "under review" message. + var userData = mockUserData(); + var versions = mockExtension(userData, 1, 0, 0); + var latest = versions.getLast(); + var extension = latest.getExtension(); + extension.setActive(false); + var namespace = extension.getNamespace(); + + var token = new PersonalAccessToken(); + token.setUser(userData); + latest.setPublishedWith(token); + + var membership = new NamespaceMembership(); + membership.setNamespace(namespace); + membership.setUser(userData); + membership.setRole(NamespaceMembership.ROLE_CONTRIBUTOR); + Mockito.when(repositories.findMemberships(userData)).thenReturn(Streamable.of(membership)); + Mockito.when(repositories.isVerifiedPublisher(latest)).thenReturn(false); + + var scan = new ExtensionScan(); + scan.setStatus(ScanStatus.QUARANTINED); + Mockito.when( + scanRepository + .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( + namespace.getName(), + extension.getName(), + latest.getVersion(), + TargetPlatform.NAME_UNIVERSAL)) + .thenReturn(scan); + Mockito.when(repositories.findExtensionThreats(scan, NamespaceOwnershipCheckScanner.TYPE)) + .thenReturn( + Streamable.of( + ExtensionThreat.create( + null, + null, + null, + NamespaceOwnershipCheckScanner.TYPE, + NamespaceOwnershipCheckScanner.TYPE + "-conflict", + "Namespace 'foobar' exists in the referenced gallery, but is not verified.", + "high"))); + + mockMvc.perform( + get("/user/extensions") + .with(user("test_user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].verified").value(false)) + .andExpect(jsonPath("$[0].namespaceOwnershipConflict").value(true)); + } + @Test void testGetOwnExtensionAsNamespaceOwner() throws Exception { var userData = mockUserData(); @@ -436,6 +491,53 @@ void testGetOwnExtensionAsNamespaceMember() throws Exception { .andExpect(jsonPath("$.allTargetPlatformVersions[1].canDelete").value(true)); } + @Test + void testGetOwnExtensionNamespaceOwnershipConflict() throws Exception { + var userData = mockUserData(); + var versions = mockExtension(userData, 2, 0, 0); + var latest = versions.getLast(); + latest.setId(42L); + var extension = latest.getExtension(); + Mockito.when(repositories.isNamespaceOwner(any(UserData.class), any(Namespace.class))).thenReturn(true); + Mockito.when(repositories.findLatestVersion(eq("foobar"), eq("baz"), any(), eq(false), eq(false))) + .thenReturn(latest); + Mockito.when(repositories.findTargetPlatformsGroupedByVersion(extension)).thenReturn(List.of()); + Mockito.when( + storageUtil.getFileUrls( + Mockito.anyCollection(), + Mockito.anyString(), + Mockito.any(String[].class))) + .thenReturn(java.util.Map.of(42L, new java.util.HashMap<>())); + + var scan = new ExtensionScan(); + scan.setStatus(ScanStatus.QUARANTINED); + Mockito.when( + scanRepository + .findFirstByNamespaceNameAndExtensionNameAndExtensionVersionAndTargetPlatformOrderByStartedAtDesc( + "foobar", + "baz", + latest.getVersion(), + TargetPlatform.NAME_UNIVERSAL)) + .thenReturn(scan); + Mockito.when(repositories.findExtensionThreats(scan, NamespaceOwnershipCheckScanner.TYPE)) + .thenReturn( + Streamable.of( + ExtensionThreat.create( + null, + null, + null, + NamespaceOwnershipCheckScanner.TYPE, + NamespaceOwnershipCheckScanner.TYPE + "-conflict", + "Namespace 'foobar' exists in the referenced gallery, but is not verified.", + "high"))); + + mockMvc.perform( + get("/user/extension/{namespace}/{extension}", "foobar", "baz") + .with(user("test_user"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.namespaceOwnershipConflict").value(true)); + } + @Test void testGetOwnExtensionNotMember() throws Exception { var userData = mockUserData(); diff --git a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java index 00fbfc78d..71873825c 100644 --- a/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java +++ b/server/src/test/java/org/eclipse/openvsx/repositories/RepositoryServiceSmokeTest.java @@ -198,6 +198,8 @@ void testExecuteQueries() { () -> repositories.countExtensions(), () -> repositories.hasMemberships(namespace, "role"), () -> repositories.isVerified(namespace, userData), + () -> repositories.isVerifiedPublisher(namespace, userData), + () -> repositories.isVerifiedPublisher(extVersion), () -> repositories.countNamespaces(), () -> repositories.countPublishersThatClaimedNamespaceOwnership(), () -> repositories.countUsers(), diff --git a/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java b/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java new file mode 100644 index 000000000..7da80e2f2 --- /dev/null +++ b/server/src/test/java/org/eclipse/openvsx/scanning/NamespaceOwnershipCheckScannerTest.java @@ -0,0 +1,280 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ +package org.eclipse.openvsx.scanning; + +import java.util.List; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import org.eclipse.openvsx.adapter.ExtensionQueryResult; +import org.eclipse.openvsx.entities.Extension; +import org.eclipse.openvsx.entities.ExtensionVersion; +import org.eclipse.openvsx.entities.Namespace; +import org.eclipse.openvsx.entities.PersonalAccessToken; +import org.eclipse.openvsx.entities.UserData; +import org.eclipse.openvsx.repositories.RepositoryService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NamespaceOwnershipCheckScannerTest { + + @Mock + private RestTemplate restTemplate; + @Mock + private RepositoryService repositories; + @Mock + private EntityManager entityManager; + @Mock + private ScannerRegistry scannerRegistry; + + /** + * A search result containing an extension published under the "acme" namespace - the exact + * namespace under test - simulating that the namespace is already claimed upstream. + */ + private static final ExtensionQueryResult NAMESPACE_MATCH = searchResult(extensionOf("acme")); + + /** + * A search result containing an extension published under "ACME" (different casing) - upstream + * publisher ids are case-insensitive, so this should still count as a match for "acme". + */ + private static final ExtensionQueryResult NAMESPACE_MATCH_DIFFERENT_CASE = searchResult(extensionOf("ACME")); + + /** + * A search result that only contains extensions from other publishers - simulating a loose, + * merely-relevant search hit that must NOT be mistaken for the "acme" namespace existing. + */ + private static final ExtensionQueryResult UNRELATED_PUBLISHERS = searchResult(extensionOf("acme-tools")); + + /** + * No search results at all. + */ + private static final ExtensionQueryResult EMPTY = new ExtensionQueryResult( + List.of(new ExtensionQueryResult.ResultItem(List.of(), List.of()))); + + private static ExtensionQueryResult searchResult(ExtensionQueryResult.Extension... extensions) { + return new ExtensionQueryResult( + List.of(new ExtensionQueryResult.ResultItem(List.of(extensions), List.of()))); + } + + private static ExtensionQueryResult.Extension extensionOf(String publisherName) { + var publisher = new ExtensionQueryResult.Publisher(null, null, publisherName, null, null); + return new ExtensionQueryResult.Extension( + null, + null, + null, + null, + publisher, + null, + null, + null, + null, + null, + null, + null, + null); + } + + private NamespaceOwnershipCheckScanner newScanner(boolean enforced, boolean checkActiveExtensions) { + var config = new NamespaceOwnershipCheckConfig( + true, + true, + enforced, + checkActiveExtensions, + "http://irrelevant"); + return new NamespaceOwnershipCheckScanner( + config, + restTemplate, + repositories, + entityManager, + scannerRegistry); + } + + private ExtensionVersion extensionVersion(UserData publisher) { + var namespace = new Namespace(); + namespace.setName("acme"); + + var extension = new Extension(); + extension.setName("widget"); + extension.setNamespace(namespace); + + var extVersion = new ExtensionVersion(); + extVersion.setExtension(extension); + if (publisher != null) { + var token = new PersonalAccessToken(); + token.setUser(publisher); + extVersion.setPublishedWith(token); + } + return extVersion; + } + + @Test + void startScan_throws_whenEnforcedAndUpstreamIsDown() { + var extVersion = extensionVersion(new UserData()); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenThrow(new RestClientException("whatever")); + + assertThrows(ScannerException.class, () -> newScanner(true, true).startScan(new Scanner.Command(1L, "scan-1"))); + } + + @Test + void startScan_isClean_whenNotEnforcedAndUpstreamIsDown() throws Exception { + var extVersion = extensionVersion(new UserData()); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenThrow(new RestClientException("whatever")); + + var invocation = (Scanner.Invocation.Completed) newScanner(false, true) + .startScan(new Scanner.Command(1L, "scan-1")); + assertTrue(invocation.result().isClean()); + } + + @Test + void startScan_isClean_whenNamespaceDoesNotExistUpstream() throws Exception { + var extVersion = extensionVersion(new UserData()); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(EMPTY); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertTrue(invocation.result().isClean()); + verify(repositories, never()).isVerifiedPublisher(any(), any()); + } + + @Test + void startScan_isClean_whenSearchHitsAreFromUnrelatedPublishers() throws Exception { + // The upstream search is loose (free text), so it may return extensions that merely mention + // the namespace name without actually being published under it - those must not count. + var extVersion = extensionVersion(new UserData()); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(UNRELATED_PUBLISHERS); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertTrue(invocation.result().isClean()); + verify(repositories, never()).isVerifiedPublisher(any(), any()); + } + + @Test + void startScan_raisesThreat_whenNamespaceExistsUpstreamAndIsNotVerified() throws Exception { + var user = new UserData(); + var extVersion = extensionVersion(user); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); + when(repositories.isVerifiedPublisher(extVersion.getExtension().getNamespace(), user)).thenReturn(false); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertFalse(invocation.result().isClean()); + assertEquals(1, invocation.result().getThreats().size()); + } + + @Test + void startScan_isClean_whenNamespaceExistsUpstreamAndIsVerified() throws Exception { + var user = new UserData(); + var extVersion = extensionVersion(user); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); + when(repositories.isVerifiedPublisher(extVersion.getExtension().getNamespace(), user)).thenReturn(true); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertTrue(invocation.result().isClean()); + assertEquals(0, invocation.result().getThreats().size()); + } + + @Test + void startScan_raisesThreat_whenNamespaceExistsUpstreamWithDifferentCasing() throws Exception { + // Upstream publisher ids are case-insensitive, so "ACME" must still be recognized as a match + // for the "acme" namespace being published to. + var user = new UserData(); + var extVersion = extensionVersion(user); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH_DIFFERENT_CASE); + when(repositories.isVerifiedPublisher(extVersion.getExtension().getNamespace(), user)).thenReturn(false); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertFalse(invocation.result().isClean()); + assertEquals(1, invocation.result().getThreats().size()); + } + + @Test + void startScan_raisesThreat_whenExistsUpstreamAndNoPublishingUserIsAttributed() throws Exception { + var extVersion = extensionVersion(null); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertFalse(invocation.result().isClean()); + verify(repositories, never()).isVerifiedPublisher(any(), any()); + } + + @Test + void startScan_isClean_isActiveAndCheckActiveExtensions() throws Exception { + var user = new UserData(); + var extVersion = extensionVersion(user); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(restTemplate.postForObject(anyString(), any(), any())).thenReturn(NAMESPACE_MATCH); + when(repositories.isVerifiedPublisher(extVersion.getExtension().getNamespace(), user)).thenReturn(true); + when(repositories.findActiveExtension(anyString(), anyString())).thenReturn(extVersion.getExtension()); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, true) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertTrue(invocation.result().isClean()); + verify(restTemplate, atLeastOnce()).postForObject(anyString(), any(), any()); + } + + @Test + void startScan_isClean_isActiveAndNotCheckActiveExtensions() throws Exception { + var user = new UserData(); + var extVersion = extensionVersion(user); + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(extVersion); + when(repositories.findActiveExtension(anyString(), anyString())).thenReturn(extVersion.getExtension()); + + var invocation = (Scanner.Invocation.Completed) newScanner(true, false) + .startScan(new Scanner.Command(1L, "scan-1")); + + assertTrue(invocation.result().isClean()); + verify(restTemplate, never()).postForObject(anyString(), any(), any()); + } + + @Test + void startScan_throws_whenExtensionVersionNotFound() { + when(entityManager.find(ExtensionVersion.class, 1L)).thenReturn(null); + + assertThrows(ScannerException.class, () -> newScanner(true, true).startScan(new Scanner.Command(1L, "scan-1"))); + } +} diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index b53fdc673..d9bcd6683 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -7,6 +7,9 @@ This change log covers only the frontend library (webui) of Open VSX. ### Added - Add a "Data Consistency" page to the admin dashboard (#1622): a live overview of every registered consistency check's finding count, with actions to refresh it and to fix findings one at a time or all at once +- Show a "Namespace needs verification" hint on an extension card when it can't be activated because its namespace already exists in a referenced external gallery and hasn't been verified, in both the "My Extensions" and namespace member extension lists +- Add a "Claim Namespace" action to the extension settings page, next to "View in Marketplace", when the extension has a namespace ownership conflict: uses the deployment's configured `elements.claimNamespace`, falling back to the namespace access documentation when none is configured +- Show a "Namespace Not Verified" status chip and an explanatory notice on the extension settings page when the extension has a namespace ownership conflict, making clear the namespace must be claimed before it can be activated ### Fixed diff --git a/webui/src/components/extension/extension-card-list-item.tsx b/webui/src/components/extension/extension-card-list-item.tsx index bd907de3d..2afc6fc1a 100644 --- a/webui/src/components/extension/extension-card-list-item.tsx +++ b/webui/src/components/extension/extension-card-list-item.tsx @@ -58,6 +58,20 @@ export const ExtensionCardListItem: FunctionComponent + + Namespace needs verification + + + This namespace already exists on a referenced gallery and needs to be verified before this + extension can be activated. + + + ); + } + if (extension.reviewStatus === 'under_review') { return ( diff --git a/webui/src/components/extension/extension-detail-view.tsx b/webui/src/components/extension/extension-detail-view.tsx index 54af7bdb4..fa3c70af1 100644 --- a/webui/src/components/extension/extension-detail-view.tsx +++ b/webui/src/components/extension/extension-detail-view.tsx @@ -11,9 +11,11 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import { FunctionComponent, ReactNode, useEffect, useState } from 'react'; +import { FunctionComponent, ReactNode, useContext, useEffect, useState } from 'react'; import { Box, Button, Divider, Stack, Typography } from '@mui/material'; import { Link as RouteLink } from 'react-router'; +import WarningIcon from '@mui/icons-material/Warning'; +import { MainContext } from '../../context'; import { Extension, VERSION_ALIASES, VersionTargetPlatforms } from '../../extension-registry-types'; import { ExtensionHeader } from './extension-header'; import { ExtensionStatusChips } from './extension-status-chips'; @@ -25,9 +27,28 @@ import { useTrustedPublishingStatus } from '../../pages/user/trusted-publishing/ import { ExtensionDetailRoutes } from '../../pages/extension-detail/extension-detail-routes'; import { createRoute } from '../../utils'; +// `claimNamespace` renders as a Link (its target action is pluggable, e.g. an external issue +// template), styled here to sit as a button among the other actions in the Stack below. +const claimNamespaceButtonStyle = { + display: 'inline-flex', + alignItems: 'center', + px: 2, + py: '5px', + border: '1px solid', + borderColor: 'warning.main', + borderRadius: 1, + fontWeight: 500, + fontSize: '0.875rem', + lineHeight: 1.75, + textTransform: 'uppercase', + '&:hover': { textDecoration: 'none' } +}; + export const ExtensionDetailView: FunctionComponent = props => { const { extension, actions, onRemoveVersion, onVersionDeleted, onPurgeVersion } = props; const canPurge = !!onPurgeVersion; + const { pageSettings } = useContext(MainContext); + const ClaimNamespace = pageSettings.elements.claimNamespace; const [page, setPage] = useState(0); const [deleteDialogVersion, setDeleteDialogVersion] = useState(null); @@ -58,6 +79,15 @@ export const ExtensionDetailView: FunctionComponent = )} + {extension.namespaceOwnershipConflict && ( + + + This namespace already exists in a referenced gallery and needs to be claimed (verified) before this + extension can be activated. + + )} {extension.active && ( @@ -65,6 +95,21 @@ export const ExtensionDetailView: FunctionComponent = View in Marketplace )} + {extension.namespaceOwnershipConflict && + (ClaimNamespace ? ( + + ) : ( + // Fallback for a deployment that hasn't configured `elements.claimNamespace`: + // point at the generic namespace-access docs instead of showing nothing. + + ))}