You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/09/17 15:59:35 UTC

(maven) branch maven-4.0.x updated: Fix #13084: sandbox profile activation context for external model builds (#13158)

This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch maven-4.0.x
in repository https://gitbox.apache.org/repos/asf/maven.git


The following commit(s) were added to refs/heads/maven-4.0.x by this push:
     new ee0ed81d88 Fix #13084: sandbox profile activation context for external model builds (#13158)
ee0ed81d88 is described below

commit ee0ed81d88fad18458841d71cd75d729d52af98e
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Thu Sep 17 17:59:28 2026 +0200

    Fix #13084: sandbox profile activation context for external model builds (#13158)
    
    * Fix #13084: sandbox profile activation context for external model builds
    
    Backport of #13112 (master) and #13114 (maven-3.10.x) to maven-4.0.x.
    
    Instead of blanket-filtering file and property profiles in external model
    builds (dependency POMs, parent POMs, imported BOMs), evaluate them against
    a sandboxed ProfileActivationContext that:
    
     - Merges model properties into system property lookups (so POM-declared
       <properties> drive property-activated profiles in dependency builds).
     - Suppresses user properties (consumer -D flags must not activate
       dependency profiles — they were not set for that artifact).
     - Pre-filters file-activated profiles (returning false from exists() would
       incorrectly activate <missing> profiles).
     - Strips repository contributions from all active external profiles.
    
    Both compat and impl stacks are updated identically. The key insight is that
    model properties are merged into system properties inside the sandbox rather
    than changing PropertyProfileActivator's lookup chain, to avoid unintended
    activation when a project POM declares a property that coincidentally matches
    a profile's activation condition.
    
    Tests: ExternalModelProfileActivationTest (compat) and DefaultModelBuilderTest
    (impl) cover file suppression, user-property suppression, system-property
    preservation, POM-declared-property activation, negated-property default-on
    preservation, and system-property opt-out of the default-on pattern.
    
    * Address review: fix mutable map leak in compat sandbox context, pre-filter missing()-condition profiles in CONSUMER_DEPENDENCY builds
---
 .../services/model/ProfileActivationContext.java   |  11 +
 .../maven/model/building/DefaultModelBuilder.java  |  95 +++++++-
 .../model/profile/ProfileActivationContext.java    |  10 +
 .../activation/PropertyProfileActivator.java       |   5 +
 .../ExternalModelProfileActivationTest.java        | 251 +++++++++++++++++++--
 .../maven/impl/model/DefaultModelBuilder.java      |  63 ++++--
 .../model/DefaultProfileActivationContext.java     |  98 ++++++++
 .../model/profile/PropertyProfileActivator.java    |   5 +
 .../maven/impl/model/DefaultModelBuilderTest.java  |  59 ++++-
 .../poms/factory/resolved-model-with-profiles.xml  |  24 ++
 10 files changed, 570 insertions(+), 51 deletions(-)

diff --git a/api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ProfileActivationContext.java b/api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ProfileActivationContext.java
index c9e73242f6..e3cf95c811 100644
--- a/api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ProfileActivationContext.java
+++ b/api/maven-api-spi/src/main/java/org/apache/maven/api/services/model/ProfileActivationContext.java
@@ -63,6 +63,11 @@ public interface ProfileActivationContext {
     /**
      * Gets the user property to use for interpolation and profile activation. The user properties have been
      * configured directly by the user on his discretion, e.g. via the {@code -Dkey=value} parameter on the command line.
+     * <p>
+     * <b>In repository-resolved (external) model builds</b> (dependency POMs, parent POMs, imported BOMs),
+     * this always returns {@code null}. Consumer {@code -D} flags are not propagated to dependency profile
+     * activation: the consumer did not set those flags for the dependency, and doing so would make dependency
+     * resolution non-deterministic and open to accidental (or malicious) profile injection.
      *
      * @param key the name of the user property
      * @return The user property for the specified key, or {@code null}.
@@ -72,6 +77,12 @@ public interface ProfileActivationContext {
 
     /**
      * Gets the model property to use for interpolation and profile activation.
+     * <p>
+     * These are the properties declared in the model's own {@code <properties>} section.
+     * They are part of the artifact's published identity and are always consulted for profile
+     * activation, including in repository-resolved (external) model builds. Unlike
+     * {@link #getUserProperty(String)}, these are never suppressed when evaluating dependency
+     * profiles.
      *
      * @param key the name of the model property
      * @return The model property for the specified key, or {@code null};
diff --git a/compat/maven-model-builder/src/main/java/org/apache/maven/model/building/DefaultModelBuilder.java b/compat/maven-model-builder/src/main/java/org/apache/maven/model/building/DefaultModelBuilder.java
index 1160e5cd96..997994b089 100644
--- a/compat/maven-model-builder/src/main/java/org/apache/maven/model/building/DefaultModelBuilder.java
+++ b/compat/maven-model-builder/src/main/java/org/apache/maven/model/building/DefaultModelBuilder.java
@@ -314,9 +314,11 @@ protected ModelBuildingResult build(ModelBuildingRequest request, Collection<Str
             List<Profile> interpolatedProfiles = getInterpolatedProfiles(rawModel, profileActivationContext, problems);
             tmpModel.setProfiles(interpolatedProfiles);
 
+            List<Profile> profilesToEvaluate =
+                    externalModel ? withoutFileActivation(tmpModel.getProfiles()) : tmpModel.getProfiles();
             List<Profile> activePomProfiles = profileSelector.getActiveProfiles(
-                    externalModel ? withoutFileAndPropertyActivation(interpolatedProfiles) : tmpModel.getProfiles(),
-                    profileActivationContext,
+                    profilesToEvaluate,
+                    externalModel ? externalActivationContext(profileActivationContext) : profileActivationContext,
                     problems);
 
             List<Profile> rawProfiles = new ArrayList<>();
@@ -515,16 +517,93 @@ private static boolean isExternalModelBuildingRequest(ModelBuildingRequest reque
     }
 
     /**
-     * Returns the profiles from the given list whose activation does not depend on a file or a
-     * property. Profiles activated by JDK version, operating system, or marked
-     * {@code activeByDefault} are unaffected, since those conditions are a function of the build
-     * platform rather than of the model content.
+     * Returns a sandboxed {@link ProfileActivationContext} for evaluating profiles in
+     * repository-resolved (external) models — dependency POMs, parent POMs, and imported BOMs.
+     * <p>
+     * The sandboxed context preserves system properties (so JDK/OS activation works) and
+     * merges the POM's own {@code <properties>} into the system properties map so that
+     * property-activated profiles that depend on POM-declared values still work.
+     * User properties (consumer {@code -D} flags) are suppressed because they were not
+     * set for the dependency and must not accidentally activate its profiles.
+     * File-based profiles are pre-filtered via {@link #withoutFileActivation(List)} before
+     * reaching this context, so {@code getProjectDirectory()} is not relied on for file checks.
+     * <p>
+     * Model properties are merged into system properties (with system properties taking
+     * precedence) rather than changing the {@code PropertyProfileActivator} lookup chain,
+     * because changing the activator would affect ALL profile evaluations — including the
+     * build's own project — which can cause unintended profile activation when a POM declares
+     * a property that matches a profile's activation condition.
+     *
+     * @param delegate the original full context for this model build
+     * @return a sandboxed context suitable for external model profile activation
      */
-    private static List<Profile> withoutFileAndPropertyActivation(List<Profile> profiles) {
+    private static ProfileActivationContext externalActivationContext(ProfileActivationContext delegate) {
+        // Pre-compute the merged system+project properties once per external model.
+        // getSystemProperties() may be called multiple times per profile (e.g. OperatingSystemProfileActivator
+        // calls it 3× for name/arch/version), so allocating a new HashMap on every call is O(deps × profiles ×
+        // |systemProperties|). Computing it eagerly here keeps the anonymous class allocation-free.
+        final Map<String, String> mergedSystemProps;
+        Map<String, String> projectProps = delegate.getProjectProperties();
+        if (projectProps == null || projectProps.isEmpty()) {
+            mergedSystemProps = Collections.unmodifiableMap(delegate.getSystemProperties());
+        } else {
+            Map<String, String> merged = new HashMap<>(projectProps);
+            merged.putAll(delegate.getSystemProperties()); // system wins
+            mergedSystemProps = Collections.unmodifiableMap(merged);
+        }
+        return new ProfileActivationContext() {
+            @Override
+            public List<String> getActiveProfileIds() {
+                return delegate.getActiveProfileIds();
+            }
+
+            @Override
+            public List<String> getInactiveProfileIds() {
+                return delegate.getInactiveProfileIds();
+            }
+
+            /**
+             * System properties merged with project properties (system wins on conflict).
+             * This makes POM-declared properties visible to the PropertyProfileActivator
+             * without modifying the activator's lookup chain for non-external models.
+             */
+            @Override
+            public Map<String, String> getSystemProperties() {
+                return mergedSystemProps;
+            }
+
+            /** User properties are suppressed: consumer -D flags do not activate dependency profiles. */
+            @Override
+            public Map<String, String> getUserProperties() {
+                return Collections.emptyMap();
+            }
+
+            @Override
+            public Map<String, String> getProjectProperties() {
+                return delegate.getProjectProperties();
+            }
+
+            @Override
+            public File getProjectDirectory() {
+                return delegate.getProjectDirectory();
+            }
+        };
+    }
+
+    /**
+     * Returns the profiles from the given list whose activation does not depend on a file.
+     * File-activated profiles are suppressed in external model builds because publisher-local
+     * paths do not exist in the consumer's environment and resolving them against the consumer's
+     * filesystem would produce non-deterministic or incorrect results.
+     *
+     * @param profiles the full profile list
+     * @return profiles with file-activated ones removed
+     */
+    private static List<Profile> withoutFileActivation(List<Profile> profiles) {
         List<Profile> eligible = new ArrayList<>(profiles.size());
         for (Profile profile : profiles) {
             Activation activation = profile.getActivation();
-            if (activation == null || (activation.getFile() == null && activation.getProperty() == null)) {
+            if (activation == null || activation.getFile() == null) {
                 eligible.add(profile);
             }
         }
diff --git a/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/ProfileActivationContext.java b/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/ProfileActivationContext.java
index cb529cd0e4..ccb5bdc9a3 100644
--- a/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/ProfileActivationContext.java
+++ b/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/ProfileActivationContext.java
@@ -62,6 +62,11 @@ public interface ProfileActivationContext {
      * Gets the user properties to use for interpolation and profile activation. The user properties have been
      * configured directly by the user on his discretion, e.g. via the {@code -Dkey=value} parameter on the command
      * line.
+     * <p>
+     * <b>In repository-resolved (external) model builds</b> (dependency POMs, parent POMs, imported BOMs), this
+     * map is intentionally empty. Consumer {@code -D} flags are not propagated to dependency profile activation:
+     * the consumer did not set those flags for the dependency, and doing so would make dependency resolution
+     * non-deterministic and open to accidental (or malicious) profile injection.
      *
      * @return The user properties, never {@code null}.
      */
@@ -76,6 +81,11 @@ public interface ProfileActivationContext {
 
     /**
      * Gets current calculated project properties
+     * <p>
+     * These are the properties declared in the model's own {@code <properties>} section.
+     * They are part of the artifact's published identity and are consulted for profile activation
+     * in all contexts, including repository-resolved (external) model builds. Unlike
+     * {@link #getUserProperties()}, these are never suppressed when evaluating dependency profiles.
      *
      * @return The project properties, never {@code null}.
      */
diff --git a/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/activation/PropertyProfileActivator.java b/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/activation/PropertyProfileActivator.java
index 9873874b93..3b4bfc8da8 100644
--- a/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/activation/PropertyProfileActivator.java
+++ b/compat/maven-model-builder/src/main/java/org/apache/maven/model/profile/activation/PropertyProfileActivator.java
@@ -71,6 +71,11 @@ public boolean isActive(Profile profile, ProfileActivationContext context, Model
             return false;
         }
 
+        // Lookup order: user (-D) → system (java.version, os.name, …).
+        // In external model builds the caller provides a sandboxed context
+        // (see DefaultModelBuilder.externalActivationContext()) that suppresses user properties
+        // and merges model properties into system properties, so consumer -D flags cannot
+        // activate dependency profiles while POM-declared properties still drive activation.
         String sysValue = context.getUserProperties().get(name);
         if (sysValue == null) {
             sysValue = context.getSystemProperties().get(name);
diff --git a/compat/maven-model-builder/src/test/java/org/apache/maven/model/building/ExternalModelProfileActivationTest.java b/compat/maven-model-builder/src/test/java/org/apache/maven/model/building/ExternalModelProfileActivationTest.java
index e3a8c669f2..b38096c148 100644
--- a/compat/maven-model-builder/src/test/java/org/apache/maven/model/building/ExternalModelProfileActivationTest.java
+++ b/compat/maven-model-builder/src/test/java/org/apache/maven/model/building/ExternalModelProfileActivationTest.java
@@ -28,25 +28,54 @@
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
- * Models built at {@link ModelBuildingRequest#VALIDATION_LEVEL_MINIMAL} come from POMs resolved
- * from a repository during dependency resolution (a dependency POM, one of its parents, or an
- * imported BOM), see for instance {@code DefaultArtifactDescriptorReader#loadPom}. Their file and
- * property activators are not evaluated. Platform-derived activation (JDK version, operating
- * system, activeByDefault) is unaffected at either level. Repositories declared in
- * legitimately-active profiles (JDK/OS/activeByDefault) are honored — stripping them would break
- * the established {@code project → dep1 → dep2} pattern where dep1 declares dep2's non-Central
- * repository inside a JDK- or activeByDefault-activated profile. A project build, at
- * {@link ModelBuildingRequest#VALIDATION_LEVEL_STRICT}, still evaluates every activator.
+ * Tests for profile activation in external (repository-resolved) model builds, i.e. those at
+ * {@link ModelBuildingRequest#VALIDATION_LEVEL_MINIMAL} (dependency POMs, parent POMs, imported BOMs).
+ *
+ * <h2>Activation rules for external models</h2>
+ * <ul>
+ *   <li><b>JDK / OS / activeByDefault</b> — always re-activated: they are platform facts.</li>
+ *   <li><b>File</b> — suppressed: publisher-local paths don't exist on the consumer's machine.</li>
+ *   <li><b>Property (user / -D)</b> — suppressed: consumer flags were not set for that dependency
+ *       and must not accidentally activate its profiles.</li>
+ *   <li><b>Property (system)</b> — re-activated: system properties are platform facts
+ *       ({@code java.version}, {@code os.name}, …).</li>
+ *   <li><b>Property (POM-declared, from &lt;properties&gt;)</b> — re-activated: the POM's own
+ *       properties are part of the artifact's published identity, not the consumer's environment.
+ *       A profile conditioned on a POM-declared property is publisher-intent, not consumer-injected.</li>
+ *   <li><b>Negated property (-D absent)</b> — re-activated (default-on): the property is absent from
+ *       the sandboxed context, so {@code !foo} evaluates to true, preserving the publisher's intent.</li>
+ *   <li><b>Profile repositories</b> — honored from legitimately-active profiles (JDK/OS/activeByDefault).
+ *       Stripping them would break the established {@code project → dep1 → dep2} pattern where dep1
+ *       declares dep2's non-Central repository inside a JDK- or activeByDefault-activated profile.
+ *       See #13100, #13141.</li>
+ * </ul>
  */
 class ExternalModelProfileActivationTest {
 
+    /**
+     * POM with profiles covering all activation types.
+     * <p>
+     * For the file test, {@code some.dir} is set as a system property pointing to a directory that
+     * exists (tmpdir); in a STRICT project build this profile fires, but in external builds file
+     * activation is suppressed entirely.
+     * <p>
+     * For the user-property test, {@code user.gating.prop} must be set via user properties (-D),
+     * not system properties, to test the suppression of -D flags in external builds.
+     * <p>
+     * For the system-property test, {@code sys.gating.prop} is set as a system property and
+     * must remain effective in external builds (system properties are platform facts).
+     */
     private static final String POM = "<project>\n"
             + "  <modelVersion>4.0.0</modelVersion>\n"
             + "  <groupId>thegroup</groupId>\n"
             + "  <artifactId>withprofiles</artifactId>\n"
             + "  <version>1</version>\n"
             + "  <packaging>pom</packaging>\n"
+            + "  <properties>\n"
+            + "    <pom.declared.prop>hello</pom.declared.prop>\n"
+            + "  </properties>\n"
             + "  <profiles>\n"
+            // profile 1: file activation — suppressed in external builds
             + "    <profile>\n"
             + "      <id>file-condition</id>\n"
             + "      <activation>\n"
@@ -58,17 +87,55 @@ class ExternalModelProfileActivationTest {
             + "        <profile.file>activated</profile.file>\n"
             + "      </properties>\n"
             + "    </profile>\n"
+            // profile 2: user-property activation — suppressed in external builds
+            + "    <profile>\n"
+            + "      <id>user-property-condition</id>\n"
+            + "      <activation>\n"
+            + "        <property>\n"
+            + "          <name>user.gating.prop</name>\n"
+            + "        </property>\n"
+            + "      </activation>\n"
+            + "      <properties>\n"
+            + "        <profile.user.property>activated</profile.user.property>\n"
+            + "      </properties>\n"
+            + "    </profile>\n"
+            // profile 3: system-property activation — must fire in external builds
             + "    <profile>\n"
-            + "      <id>property-condition</id>\n"
+            + "      <id>system-property-condition</id>\n"
             + "      <activation>\n"
             + "        <property>\n"
-            + "          <name>some.gating.property</name>\n"
+            + "          <name>sys.gating.prop</name>\n"
             + "        </property>\n"
             + "      </activation>\n"
             + "      <properties>\n"
-            + "        <profile.property>activated</profile.property>\n"
+            + "        <profile.sys.property>activated</profile.sys.property>\n"
             + "      </properties>\n"
             + "    </profile>\n"
+            // profile 4: POM-declared property activation (positive) — must fire in external builds
+            + "    <profile>\n"
+            + "      <id>pom-declared-property-condition</id>\n"
+            + "      <activation>\n"
+            + "        <property>\n"
+            + "          <name>pom.declared.prop</name>\n"
+            + "        </property>\n"
+            + "      </activation>\n"
+            + "      <properties>\n"
+            + "        <profile.pom.property>activated</profile.pom.property>\n"
+            + "      </properties>\n"
+            + "    </profile>\n"
+            // profile 5: negated-property (default-on, opt-out via -D) — must fire in external builds
+            + "    <profile>\n"
+            + "      <id>negated-property-default-on</id>\n"
+            + "      <activation>\n"
+            + "        <property>\n"
+            + "          <name>!skip.defaults</name>\n"
+            + "        </property>\n"
+            + "      </activation>\n"
+            + "      <properties>\n"
+            + "        <profile.negated.property>activated</profile.negated.property>\n"
+            + "      </properties>\n"
+            + "    </profile>\n"
+            // profile 6: JDK activation — always fires
             + "    <profile>\n"
             + "      <id>jdk-condition</id>\n"
             + "      <activation>\n"
@@ -87,38 +154,72 @@ class ExternalModelProfileActivationTest {
             + "  </profiles>\n"
             + "</project>\n";
 
-    private Model build(int validationLevel) throws Exception {
+    private Model build(int validationLevel, Properties systemProperties, Properties userProperties) throws Exception {
         ModelBuilder builder = new DefaultModelBuilderFactory().newInstance();
 
-        Properties systemProperties = new Properties();
-        systemProperties.putAll(System.getProperties());
-        systemProperties.setProperty("some.dir", System.getProperty("java.io.tmpdir"));
-        systemProperties.setProperty("some.gating.property", "true");
-
         DefaultModelBuildingRequest request = new DefaultModelBuildingRequest();
         request.setModelSource(new StringModelSource(POM));
         request.setValidationLevel(validationLevel);
         request.setSystemProperties(systemProperties);
+        request.setUserProperties(userProperties);
 
         return builder.build(request).getEffectiveModel();
     }
 
+    private Properties systemPropertiesWithTestValues() {
+        Properties sp = new Properties();
+        sp.putAll(System.getProperties());
+        sp.setProperty("some.dir", System.getProperty("java.io.tmpdir"));
+        sp.setProperty("sys.gating.prop", "true");
+        return sp;
+    }
+
+    private Properties userPropertiesWithTestValues() {
+        Properties up = new Properties();
+        up.setProperty("user.gating.prop", "true");
+        return up;
+    }
+
     @Test
     void testProjectBuildEvaluatesAllActivators() throws Exception {
-        Model model = build(ModelBuildingRequest.VALIDATION_LEVEL_STRICT);
+        Model model = build(
+                ModelBuildingRequest.VALIDATION_LEVEL_STRICT,
+                systemPropertiesWithTestValues(),
+                userPropertiesWithTestValues());
 
-        assertEquals("activated", model.getProperties().get("profile.file"));
-        assertEquals("activated", model.getProperties().get("profile.property"));
-        assertEquals("activated", model.getProperties().get("profile.jdk"));
-        assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())));
+        assertEquals("activated", model.getProperties().get("profile.file"), "file profile must fire in project build");
+        assertEquals(
+                "activated",
+                model.getProperties().get("profile.user.property"),
+                "user-property profile must fire in project build");
+        assertEquals(
+                "activated",
+                model.getProperties().get("profile.sys.property"),
+                "system-property profile must fire in project build");
+        // POM-declared property activation is only supported in external (sandbox) builds.
+        // In project builds, the PropertyProfileActivator does not check model properties
+        // because doing so would cause unintended profile activation when a POM declares
+        // a property that also matches a profile's activation condition (see IT proxy profile).
+        assertNull(
+                model.getProperties().get("profile.pom.property"),
+                "POM-declared-property profile must NOT fire in project build (only in external)");
+        assertEquals(
+                "activated",
+                model.getProperties().get("profile.negated.property"),
+                "negated-property profile must fire in project build");
+        assertEquals("activated", model.getProperties().get("profile.jdk"), "JDK profile must fire in project build");
+        assertTrue(
+                model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())),
+                "profile repository must be present in project build");
     }
 
     @Test
     void testDependencyPomHonorsJdkActivatedProfileRepositories() throws Exception {
-        Model model = build(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL);
+        Model model = build(
+                ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
 
         assertNull(model.getProperties().get("profile.file"));
-        assertNull(model.getProperties().get("profile.property"));
+        assertNull(model.getProperties().get("profile.user.property"));
         assertEquals("activated", model.getProperties().get("profile.jdk"));
         // Repositories from legitimately-active profiles (JDK-activated) must be honored:
         // stripping them would break the project → dep1 → dep2 pattern. See #13100, #13141.
@@ -176,4 +277,104 @@ void testActiveByDefaultProfileRepositoryHonored() throws Exception {
                 model.getRepositories().stream().anyMatch(r -> "always-active-repo".equals(r.getId())),
                 "Repository from activeByDefault profile must be retained in external dependency model");
     }
+
+    @Test
+    void testExternalModelFileActivationIsSuppressed() throws Exception {
+        // File activation must be suppressed in external model builds regardless of whether the
+        // path exists: publisher-local paths don't exist on the consumer's machine.
+        Model model = build(
+                ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL,
+                systemPropertiesWithTestValues(),
+                userPropertiesWithTestValues());
+
+        assertNull(model.getProperties().get("profile.file"), "file profile must NOT fire in external model build");
+    }
+
+    @Test
+    void testExternalModelUserPropertyActivationIsSuppressed() throws Exception {
+        // Consumer -D flags must not activate dependency profiles.
+        Model model = build(
+                ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL,
+                systemPropertiesWithTestValues(),
+                userPropertiesWithTestValues());
+
+        assertNull(
+                model.getProperties().get("profile.user.property"),
+                "user-property profile must NOT fire in external model build (consumer -D suppressed)");
+    }
+
+    @Test
+    void testExternalModelSystemPropertyActivationIsPreserved() throws Exception {
+        // System properties are platform facts and must drive activation even in external builds.
+        Model model = build(
+                ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
+
+        assertEquals(
+                "activated",
+                model.getProperties().get("profile.sys.property"),
+                "system-property profile must fire in external model build");
+    }
+
+    @Test
+    void testExternalModelPomDeclaredPropertyActivationIsPreserved() throws Exception {
+        // A profile conditioned on the POM's own <properties> must fire in external builds:
+        // those properties are part of the artifact's published identity, not the consumer's env.
+        Model model = build(
+                ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
+
+        assertEquals(
+                "activated",
+                model.getProperties().get("profile.pom.property"),
+                "POM-declared-property profile must fire in external model build");
+    }
+
+    @Test
+    void testExternalModelNegatedPropertyDefaultOnIsPreserved() throws Exception {
+        // A negated-property profile (!foo) fires when the property is absent.
+        // In the sandbox, user properties are suppressed so 'skip.defaults' is absent → fires.
+        // This is the common "opt-out flag" pattern (e.g. resteasy-default in JBoss projects).
+        Model model = build(
+                ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
+
+        assertEquals(
+                "activated",
+                model.getProperties().get("profile.negated.property"),
+                "negated-property (default-on) profile must fire in external model build");
+    }
+
+    @Test
+    void testExternalModelSystemPropertySuppressesDefaultOnProfile() throws Exception {
+        // A system property named 'skip.defaults' suppresses the default-on negated-property
+        // profile (!skip.defaults) even in external builds. System properties are platform facts
+        // that pass through the sandbox unchanged, so the publisher's opt-out mechanism still
+        // works when the consumer sets it at the JVM level (e.g. -Dskip.defaults in surefire).
+        Properties sp = systemPropertiesWithTestValues();
+        sp.setProperty("skip.defaults", "true"); // system-level opt-out
+        Model model = build(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, sp, new Properties());
+
+        assertNull(
+                model.getProperties().get("profile.negated.property"),
+                "negated-property (default-on) profile must be suppressed when system property 'skip.defaults' is set");
+    }
+
+    @Test
+    void testExternalModelJdkActivationIsPreserved() throws Exception {
+        // JDK activation is a platform fact and must fire in external builds.
+        Model model = build(
+                ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
+
+        assertEquals("activated", model.getProperties().get("profile.jdk"), "JDK profile must fire in external build");
+    }
+
+    @Test
+    void testExternalModelProfileRepositoriesHonored() throws Exception {
+        // Profile repositories from legitimately-active profiles (JDK/OS/activeByDefault) must
+        // be honored in external model builds. See #13100, #13141.
+        Model model = build(
+                ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL, systemPropertiesWithTestValues(), new Properties());
+
+        assertTrue(
+                model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())),
+                "profile repository from JDK-activated profile must be present in external model build");
+    }
 }
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
index f0ba1bdee5..2385c72e2a 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelBuilder.java
@@ -107,6 +107,7 @@
 import org.apache.maven.api.services.model.PathTranslator;
 import org.apache.maven.api.services.model.PluginConfigurationExpander;
 import org.apache.maven.api.services.model.PluginManagementInjector;
+import org.apache.maven.api.services.model.ProfileActivationContext;
 import org.apache.maven.api.services.model.ProfileInjector;
 import org.apache.maven.api.services.model.ProfileSelector;
 import org.apache.maven.api.services.model.RootLocator;
@@ -1696,25 +1697,41 @@ private void addActivePomProfiles(String modelId, List<Profile> activePomProfile
         private List<Profile> getActiveProfiles(
                 Collection<Profile> interpolatedProfiles, DefaultProfileActivationContext profileActivationContext) {
             if (isBuildRequestWithActivation()) {
-                Collection<Profile> eligibleProfiles = interpolatedProfiles;
                 if (externalOrigin) {
                     // A model resolved to satisfy dependency resolution -- a dependency POM
-                    // itself, or one of its parents, reached transitively -- evaluates only
-                    // platform-derived activation (JDK version, operating system,
-                    // activeByDefault). Repository stripping is intentionally omitted here:
-                    // file and property activation is already excluded by the
-                    // hasFileOrPropertyOrConditionActivation filter below, so only
-                    // legitimately-active profiles reach injection. Stripping their
-                    // repositories would break the established project → dep1 → dep2 pattern
-                    // where dep1 declares dep2's repository inside an activeByDefault or
-                    // JDK-activated profile.
-                    eligibleProfiles = interpolatedProfiles.stream()
-                            .filter(profile -> !hasFileOrPropertyOrConditionActivation(profile))
+                    // itself, or one of its parents, reached transitively -- evaluates profiles
+                    // against a sandboxed activation context.  The sandbox:
+                    //  - merges model properties into system property lookups (so POM-declared
+                    //    <properties> drive activation via the existing property lookup chain),
+                    //  - suppresses user properties (consumer -D flags must not activate
+                    //    dependency profiles — they were not set for that artifact),
+                    //  - disables file existence checks (publisher paths don't exist here).
+                    // Model properties are merged into system properties rather than adding
+                    // a separate lookup step in PropertyProfileActivator, because changing the
+                    // activator would affect ALL profile evaluations (including the build's own
+                    // project) and cause unintended profile activation when a POM declares a
+                    // property that matches a profile's activation condition.
+                    // File-activated profiles are pre-filtered (not just sandboxed) because
+                    // returning false from exists() would incorrectly activate <missing> profiles.
+                    // Condition profiles using exists()/missing() are also pre-filtered for the
+                    // same reason: inside the sandbox, context.exists() always returns false, so
+                    // missing(path) evaluates to !false = true and fires unconditionally.
+                    // Repository stripping is intentionally omitted here: only legitimately-active
+                    // profiles reach injection, and stripping their repositories would break the
+                    // established project → dep1 → dep2 pattern where dep1 declares dep2's
+                    // repository inside an activeByDefault or JDK-activated profile.
+                    // See #13100, #13141.
+                    Collection<Profile> nonFileProfiles = interpolatedProfiles.stream()
+                            .filter(p -> p.getActivation() == null
+                                    || (p.getActivation().getFile() == null && !hasFileConditionExpression(p)))
                             .toList();
+                    ProfileActivationContext externalContext =
+                            profileActivationContext.withoutUserPropertiesAndFilesystem();
+                    return profileSelector.getActiveProfiles(nonFileProfiles, externalContext, this);
                 }
                 // TODO(#13146): repositories contributed by external-model profiles can shadow
                 // central; a WARN/FAIL policy for URL mismatches should be added separately.
-                return profileSelector.getActiveProfiles(eligibleProfiles, profileActivationContext, this);
+                return profileSelector.getActiveProfiles(interpolatedProfiles, profileActivationContext, this);
             } else {
                 // BUILD_CONSUMER: activate only deterministic profiles whose activation is a
                 // function of the build platform (OS, JDK version, activeByDefault) rather than
@@ -1740,6 +1757,10 @@ private List<Profile> getActiveProfiles(
          * Determines whether the given profile's activation depends on file existence, a
          * property, or a condition expression, as opposed to being a function of the build
          * platform (JDK version, operating system) or {@code activeByDefault}.
+         * <p>
+         * Used for BUILD_CONSUMER model building to keep the consumer POM deterministic.
+         * For external (dependency/parent/BOM) models, the sandbox context approach is used
+         * instead — see {@link DefaultProfileActivationContext#withoutUserPropertiesAndFilesystem()}.
          */
         private static boolean hasFileOrPropertyOrConditionActivation(Profile profile) {
             Activation activation = profile.getActivation();
@@ -1750,6 +1771,22 @@ private static boolean hasFileOrPropertyOrConditionActivation(Profile profile) {
                                     && !activation.getCondition().isBlank()));
         }
 
+        /**
+         * Returns {@code true} if the profile's condition expression calls {@code exists()} or
+         * {@code missing()}.  Such profiles must be pre-filtered out when building external
+         * (repository-resolved) models: inside the sandbox, {@code context.exists()} always
+         * returns {@code false}, so {@code missing(path)} evaluates to {@code !false = true}
+         * and fires unconditionally — the same footgun that file-activated profiles expose.
+         */
+        private static boolean hasFileConditionExpression(Profile profile) {
+            Activation a = profile.getActivation();
+            if (a == null || a.getCondition() == null) {
+                return false;
+            }
+            String c = a.getCondition();
+            return c.contains("exists(") || c.contains("missing(");
+        }
+
         /**
          * Determines whether the given profile's activation includes a packaging condition.
          * Packaging-activated profiles are handled separately by the consumer POM builder's
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultProfileActivationContext.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultProfileActivationContext.java
index 0fa7765d14..cf6f7b3725 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultProfileActivationContext.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultProfileActivationContext.java
@@ -364,6 +364,104 @@ public DefaultProfileActivationContext setModel(Model model) {
         return this;
     }
 
+    /**
+     * Returns a sandboxed copy of this context suitable for evaluating profiles in
+     * repository-resolved (external) models — dependency POMs, parent POMs, and imported BOMs.
+     * <p>
+     * The sandboxed context:
+     * <ul>
+     *   <li><b>Merges model properties into system property lookups</b>: system properties
+     *       ({@code java.version}, {@code os.name}, …) are checked first (preserving JDK/OS
+     *       activation), then model properties (the POM's own {@code <properties>}) are used
+     *       as a fallback. This makes POM-declared properties visible to the
+     *       {@code PropertyProfileActivator} without modifying the activator's lookup chain
+     *       for non-external models.</li>
+     *   <li><b>Suppresses user properties</b> (consumer {@code -D} flags): they were not
+     *       set for the dependency and must not accidentally activate its profiles.</li>
+     *   <li><b>Disables file existence checks</b>: the publisher's file system paths do not
+     *       exist in the consumer's environment.  File-activated profiles should be
+     *       pre-filtered by the caller before reaching this context (the sandbox's
+     *       {@code exists()} returns {@code false} as a safety net, but that alone would
+     *       incorrectly activate {@code <missing>} profiles).</li>
+     * </ul>
+     *
+     * @return a sandboxed {@link ProfileActivationContext} for external model evaluation
+     */
+    public ProfileActivationContext withoutUserPropertiesAndFilesystem() {
+        return new ProfileActivationContext() {
+            @Override
+            public boolean isProfileActive(String profileId) {
+                return DefaultProfileActivationContext.this.isProfileActive(profileId);
+            }
+
+            @Override
+            public boolean isProfileInactive(String profileId) {
+                return DefaultProfileActivationContext.this.isProfileInactive(profileId);
+            }
+
+            /**
+             * System property lookup with model property fallback.
+             * System properties take precedence; model properties fill in as a fallback
+             * so POM-declared values drive property-activated profiles in external models.
+             */
+            @Override
+            public String getSystemProperty(String key) {
+                String value = DefaultProfileActivationContext.this.getSystemProperty(key);
+                if (value == null) {
+                    value = DefaultProfileActivationContext.this.getModelProperty(key);
+                }
+                return value;
+            }
+
+            /** User properties are suppressed: consumer {@code -D} flags do not activate dependency profiles. */
+            @Override
+            public String getUserProperty(String key) {
+                return null;
+            }
+
+            @Override
+            public String getModelProperty(String key) {
+                return DefaultProfileActivationContext.this.getModelProperty(key);
+            }
+
+            @Override
+            public String getModelArtifactId() {
+                return DefaultProfileActivationContext.this.getModelArtifactId();
+            }
+
+            @Override
+            public String getModelPackaging() {
+                return DefaultProfileActivationContext.this.getModelPackaging();
+            }
+
+            @Override
+            public String getModelRootDirectory() {
+                return DefaultProfileActivationContext.this.getModelRootDirectory();
+            }
+
+            @Override
+            public String getModelBaseDirectory() {
+                return DefaultProfileActivationContext.this.getModelBaseDirectory();
+            }
+
+            @Override
+            public String interpolatePath(String path) throws InterpolatorException {
+                return DefaultProfileActivationContext.this.interpolatePath(path);
+            }
+
+            /**
+             * File existence checks are disabled for external models: publisher paths do not
+             * exist in the consumer's environment.  File-activated profiles are pre-filtered
+             * before reaching this context (see {@code getActiveProfiles}), so this method
+             * should not normally be called; it returns {@code false} as a safety net.
+             */
+            @Override
+            public boolean exists(String path, boolean glob) {
+                return false;
+            }
+        };
+    }
+
     @Override
     public String interpolatePath(String path) throws InterpolatorException {
         if (path == null) {
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/PropertyProfileActivator.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/PropertyProfileActivator.java
index 8b2114ad36..62d0e238b3 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/PropertyProfileActivator.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/profile/PropertyProfileActivator.java
@@ -69,6 +69,11 @@ public boolean isActive(Profile profile, ProfileActivationContext context, Model
             return false;
         }
 
+        // Lookup order: user (-D) → system (java.version, os.name, …).
+        // In external model builds the caller provides a sandboxed context that suppresses
+        // user properties and merges model properties into system properties, so consumer
+        // -D flags cannot activate dependency profiles while POM-declared properties still
+        // drive activation.
         String sysValue = context.getUserProperty(name);
         if (sysValue == null && "packaging".equals(name)) {
             sysValue = context.getModelPackaging();
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java
index 10618c4424..b40530662a 100644
--- a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelBuilderTest.java
@@ -139,12 +139,17 @@ private ModelBuilderRequest.ModelBuilderRequestBuilder resolvedProfilesRequest(
             systemProperties.put(name, System.getProperty(name));
         }
         systemProperties.put("some.dir", System.getProperty("java.io.tmpdir"));
-        systemProperties.put("some.gating.property", "true");
-        systemProperties.put("some.condition.property", "true");
+        // Use user properties for gating values: they simulate -D flags and must be suppressed
+        // when building external (repository-resolved) models. System properties are reserved for
+        // platform facts (java.version, os.name, …) that survive the external-model sandbox.
+        Map<String, String> userProperties = Map.of(
+                "some.gating.property", "true",
+                "some.condition.property", "true");
         return ModelBuilderRequest.builder()
                 .session(session)
                 .requestType(requestType)
                 .systemProperties(systemProperties)
+                .userProperties(userProperties)
                 .source(Sources.buildSource(getPom("resolved-model-with-profiles")));
     }
 
@@ -157,7 +162,11 @@ public void testProjectBuildEvaluatesAllActivators() {
         assertEquals("activated", model.getProperties().get("profile.file"));
         assertEquals("activated", model.getProperties().get("profile.property"));
         assertEquals("activated", model.getProperties().get("profile.condition"));
+        // missing('/publisher/private/file') fires in a project build because that path
+        // genuinely doesn't exist on the developer's machine — normal project-build behaviour.
+        assertEquals("activated", model.getProperties().get("profile.missing.condition"));
         assertEquals("activated", model.getProperties().get("profile.jdk"));
+        assertEquals("activated", model.getProperties().get("profile.negated.property"));
         assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())));
     }
 
@@ -173,7 +182,16 @@ public void testDependencyModelActivatesOnlyEnvironmentIndependentProfiles() {
         assertNull(model.getProperties().get("profile.file"));
         assertNull(model.getProperties().get("profile.property"));
         assertNull(model.getProperties().get("profile.condition"));
+        // missing()-condition profiles must be pre-filtered in CONSUMER_DEPENDENCY builds:
+        // inside the sandbox context.exists() always returns false, so missing(path) would
+        // evaluate to !false = true and fire unconditionally — same footgun as <file><missing>.
+        assertNull(model.getProperties().get("profile.missing.condition"));
         assertEquals("activated", model.getProperties().get("profile.jdk"));
+        // Negated-property profile (!skip.defaults) fires because 'skip.defaults' is absent
+        // from the sandboxed context (user properties are suppressed; system properties do not
+        // contain it). This is the common opt-out flag pattern: the publisher's default-on
+        // profile must remain active for external builds unless a system-level override is set.
+        assertEquals("activated", model.getProperties().get("profile.negated.property"));
         // Repositories from legitimately-active profiles (JDK-activated) must be honored.
         // See #13100, #13141.
         assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())));
@@ -202,6 +220,36 @@ public void testActiveByDefaultProfileRepositoryHonored() {
                 "Repository from activeByDefault profile must be retained in external dependency model");
     }
 
+    /**
+     * A system property named {@code skip.defaults} suppresses the default-on negated-property
+     * profile ({@code !skip.defaults}) even in external (repository-resolved) model builds.
+     * System properties are platform facts that pass through the sandbox unchanged, so the
+     * publisher's opt-out mechanism still works when the consumer sets it at the JVM level.
+     */
+    @Test
+    public void testExternalModelSystemPropertySuppressesDefaultOnProfile() {
+        Map<String, String> systemProperties = new HashMap<>();
+        for (String name : System.getProperties().stringPropertyNames()) {
+            systemProperties.put(name, System.getProperty(name));
+        }
+        systemProperties.put("some.dir", System.getProperty("java.io.tmpdir"));
+        systemProperties.put("skip.defaults", "true"); // system-level opt-out
+        ModelBuilderRequest request = ModelBuilderRequest.builder()
+                .session(session)
+                .requestType(ModelBuilderRequest.RequestType.CONSUMER_DEPENDENCY)
+                .systemProperties(systemProperties)
+                .userProperties(Map.of("some.gating.property", "true", "some.condition.property", "true"))
+                .source(Sources.resolvedSource(
+                        getPom("resolved-model-with-profiles"),
+                        "org.apache.maven.test:resolved-model-with-profiles:1.0.0"))
+                .build();
+        Model model = builder.newSession().build(request).getEffectiveModel();
+
+        assertNull(
+                model.getProperties().get("profile.negated.property"),
+                "negated-property (default-on) profile must be suppressed when system property 'skip.defaults' is set");
+    }
+
     /**
      * A model built at {@link ModelBuilderRequest.RequestType#CONSUMER_DEPENDENCY} whose source
      * is one Maven was merely pointed at -- {@link Sources#buildSource} rather than a source
@@ -218,6 +266,7 @@ public void testCallerSuppliedModelActivatesAllProfiles() {
         assertEquals("activated", model.getProperties().get("profile.property"));
         assertEquals("activated", model.getProperties().get("profile.condition"));
         assertEquals("activated", model.getProperties().get("profile.jdk"));
+        assertEquals("activated", model.getProperties().get("profile.negated.property"));
         assertTrue(model.getRepositories().stream().anyMatch(r -> "profile-repo".equals(r.getId())));
     }
 
@@ -227,8 +276,6 @@ private Map<String, String> parentActivationSystemProperties() {
             systemProperties.put(name, System.getProperty(name));
         }
         systemProperties.put("some.dir", System.getProperty("java.io.tmpdir"));
-        systemProperties.put("some.gating.property", "true");
-        systemProperties.put("some.condition.property", "true");
         return systemProperties;
     }
 
@@ -241,7 +288,9 @@ private DefaultProfileActivationContext parentActivationContext(Map<String, Stri
                 List.of(),
                 List.of(),
                 systemProperties,
-                Map.of(),
+                // Gating props simulate -D flags: pass as user properties so the external-model
+                // sandbox suppresses them for dependency parents but allows them for project parents.
+                Map.of("some.gating.property", "true", "some.condition.property", "true"),
                 Model.newInstance());
     }
 
diff --git a/impl/maven-impl/src/test/resources/poms/factory/resolved-model-with-profiles.xml b/impl/maven-impl/src/test/resources/poms/factory/resolved-model-with-profiles.xml
index 46a923eba6..fd30698c24 100644
--- a/impl/maven-impl/src/test/resources/poms/factory/resolved-model-with-profiles.xml
+++ b/impl/maven-impl/src/test/resources/poms/factory/resolved-model-with-profiles.xml
@@ -52,6 +52,30 @@
                 <profile.condition>activated</profile.condition>
             </properties>
         </profile>
+        <!-- missing()-condition profile: must NOT fire in CONSUMER_DEPENDENCY builds -->
+        <!-- missing() = !exists() and in the sandbox exists() always returns false,  -->
+        <!-- so missing() would always return true — this profile must be pre-filtered. -->
+        <profile>
+            <id>missing-condition</id>
+            <activation>
+                <condition>missing('/publisher/private/file')</condition>
+            </activation>
+            <properties>
+                <profile.missing.condition>activated</profile.missing.condition>
+            </properties>
+        </profile>
+        <!-- default-on profile: fires when 'skip.defaults' is absent; suppressed when set as system property -->
+        <profile>
+            <id>negated-property-default-on</id>
+            <activation>
+                <property>
+                    <name>!skip.defaults</name>
+                </property>
+            </activation>
+            <properties>
+                <profile.negated.property>activated</profile.negated.property>
+            </properties>
+        </profile>
         <profile>
             <id>jdk-condition</id>
             <activation>