Skip to content

Commit 6ea0129

Browse files
Artur-mcollovati
andauthored
feat: support delaying installation of recently published npm packages (#24334)
Adds a minimum package age check (default disabled) so that npm, pnpm and bun are instructed not to install package versions newer than the configured threshold. This mitigates supply-chain attacks where a compromised version is briefly published to the registry. The threshold is exposed via Options#withMinimumPackageAgeDays(int); setting it to 0 disables the check. --------- Co-authored-by: Marco Collovati <marco@vaadin.com>
1 parent 2e3d63f commit 6ea0129

13 files changed

Lines changed: 216 additions & 7 deletions

File tree

‎flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/FrontendTools.java‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,18 @@ public class FrontendTools {
118118
private static final FrontendVersion SUPPORTED_NPM_VERSION = new FrontendVersion(
119119
SUPPORTED_NPM_MAJOR_VERSION, SUPPORTED_NPM_MINOR_VERSION);
120120

121-
private static final int SUPPORTED_PNPM_MAJOR_VERSION = 7;
122-
private static final int SUPPORTED_PNPM_MINOR_VERSION = 0;
121+
// pnpm 10.16.0 is the first version that supports the
122+
// minimumReleaseAge setting used to delay installation of newly
123+
// published packages as a supply-chain mitigation.
124+
private static final int SUPPORTED_PNPM_MAJOR_VERSION = 10;
125+
private static final int SUPPORTED_PNPM_MINOR_VERSION = 16;
123126

124127
private static final FrontendVersion SUPPORTED_PNPM_VERSION = new FrontendVersion(
125128
SUPPORTED_PNPM_MAJOR_VERSION, SUPPORTED_PNPM_MINOR_VERSION);
129+
// Bun 1.3.0 is the first version that supports --minimum-release-age
130+
// for the same supply-chain mitigation.
126131
private static final FrontendVersion SUPPORTED_BUN_VERSION = new FrontendVersion(
127-
1, 0, 6); // Bun 1.0.6 is the first version with "overrides" support
132+
1, 3, 0);
128133

129134
private enum BuildTool {
130135
NPM("npm", "npm-cli.js"),

‎flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/Options.java‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,14 @@ public class Options implements Serializable {
166166

167167
private boolean commercialBannerEnabled = false;
168168

169+
/**
170+
* Minimum age, in days, that an npm/pnpm/bun frontend package version must
171+
* have before it is allowed to be installed. Defaults to {@code 0}
172+
* (disabled); set to a positive value to enable as a mitigation against
173+
* malicious packages published to the registry.
174+
*/
175+
private int minimumFrontendPackageAgeDays = 0;
176+
169177
private ApplicationConfiguration applicationConfiguration;
170178

171179
/**
@@ -1095,6 +1103,44 @@ public Options withCommercialBanner(boolean enableCommercialBanner) {
10951103
return this;
10961104
}
10971105

1106+
/**
1107+
* Sets the minimum age (in days) a frontend package version must have
1108+
* before it is allowed to be installed by npm, pnpm or bun. The intent is
1109+
* to avoid pulling in brand-new versions that may have been compromised by
1110+
* a supply-chain attack but not yet detected and removed from the registry.
1111+
* <p>
1112+
* For npm this is translated to a {@code --before=<date>} argument; for
1113+
* pnpm it becomes {@code --config.minimum-release-age=<minutes>} (requires
1114+
* pnpm &ge; 10.16.0); for bun it becomes
1115+
* {@code --minimum-release-age=<seconds>} (requires bun &ge; 1.3.0).
1116+
*
1117+
* @param minimumFrontendPackageAgeDays
1118+
* minimum allowed age in days, or {@code 0} to disable the check
1119+
* @return this builder
1120+
* @throws IllegalArgumentException
1121+
* if {@code minimumFrontendPackageAgeDays} is negative
1122+
*/
1123+
public Options withMinimumFrontendPackageAgeDays(
1124+
int minimumFrontendPackageAgeDays) {
1125+
if (minimumFrontendPackageAgeDays < 0) {
1126+
throw new IllegalArgumentException(
1127+
"minimumFrontendPackageAgeDays must be >= 0");
1128+
}
1129+
this.minimumFrontendPackageAgeDays = minimumFrontendPackageAgeDays;
1130+
return this;
1131+
}
1132+
1133+
/**
1134+
* Gets the minimum age (in days) a frontend package version must have
1135+
* before npm, pnpm or bun is allowed to install it. {@code 0} means the
1136+
* check is disabled.
1137+
*
1138+
* @return the minimum allowed age in days
1139+
*/
1140+
public int getMinimumFrontendPackageAgeDays() {
1141+
return minimumFrontendPackageAgeDays;
1142+
}
1143+
10981144
/**
10991145
* Gets the frontend dependencies scanner to use. If not is not pre-set,
11001146
* this initializes a new one based on the Options set.

‎flow-build-tools/src/main/java/com/vaadin/flow/server/frontend/TaskRunNpmInstall.java‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@
2424
import java.nio.charset.StandardCharsets;
2525
import java.nio.file.Files;
2626
import java.nio.file.StandardCopyOption;
27+
import java.time.Instant;
28+
import java.time.temporal.ChronoUnit;
2729
import java.util.ArrayList;
2830
import java.util.Arrays;
2931
import java.util.HashMap;
3032
import java.util.List;
3133
import java.util.Map;
34+
import java.util.Optional;
3235
import java.util.concurrent.ExecutionException;
3336
import java.util.function.Consumer;
3437
import java.util.regex.Matcher;
@@ -289,6 +292,9 @@ private void runNpmInstall() throws ExecutionFailedException {
289292
}
290293
}
291294

295+
getMinimumFrontendPackageAgeArgument(options)
296+
.ifPresent(npmInstallCommand::add);
297+
292298
postinstallCommand.add("run");
293299
postinstallCommand.add("postinstall");
294300

@@ -425,6 +431,35 @@ static String getToolName(Options options) {
425431
}
426432
}
427433

434+
/**
435+
* Builds the install argument that prevents npm, pnpm or bun from
436+
* installing frontend package versions newer than
437+
* {@link Options#getMinimumFrontendPackageAgeDays()} days. Returns an empty
438+
* optional when the check is disabled.
439+
*/
440+
static Optional<String> getMinimumFrontendPackageAgeArgument(
441+
Options options) {
442+
int days = options.getMinimumFrontendPackageAgeDays();
443+
if (days <= 0) {
444+
return Optional.empty();
445+
}
446+
if (options.isEnableBun()) {
447+
// bun: --minimum-release-age takes a value in seconds
448+
long seconds = (long) days * 24 * 60 * 60;
449+
return Optional.of("--minimum-release-age=" + seconds);
450+
}
451+
if (options.isEnablePnpm()) {
452+
// pnpm: minimumReleaseAge is a setting (in minutes), so it has
453+
// to be passed via the --config.<name> CLI form, not as a
454+
// top-level option
455+
long minutes = (long) days * 24 * 60;
456+
return Optional.of("--config.minimum-release-age=" + minutes);
457+
}
458+
// npm: --before takes any Date.parse-able string
459+
String before = Instant.now().minus(days, ChronoUnit.DAYS).toString();
460+
return Optional.of("--before=" + before);
461+
}
462+
428463
private void consumeProcessOutput(Process process,
429464
Consumer<String> consumer)
430465
throws IOException, InterruptedException {

‎flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/FrontendToolsTest.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ class FrontendToolsTest {
8181

8282
private static final String OLD_PNPM_VERSION = "4.5.0";
8383

84-
private static final String SUPPORTED_PNPM_VERSION = "7.0.0";
84+
private static final String SUPPORTED_PNPM_VERSION = "10.16.0";
8585

8686
private String baseDir;
8787

‎flow-build-tools/src/test/java/com/vaadin/flow/server/frontend/TaskRunNpmInstallTest.java‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.ArrayList;
2525
import java.util.Comparator;
2626
import java.util.List;
27+
import java.util.Optional;
2728

2829
import net.jcip.annotations.NotThreadSafe;
2930
import org.junit.jupiter.api.BeforeEach;
@@ -763,4 +764,51 @@ private void assumeNPMIsInUse() {
763764
assumeTrue(getClass().equals(TaskRunNpmInstallTest.class));
764765
}
765766

767+
@Test
768+
void minimumFrontendPackageAge_defaultIsDisabled_returnsEmpty() {
769+
// Default is 0 (disabled) — no flag must be added
770+
assertFalse(TaskRunNpmInstall.getMinimumFrontendPackageAgeArgument(
771+
new MockOptions(npmFolder)).isPresent());
772+
assertFalse(TaskRunNpmInstall
773+
.getMinimumFrontendPackageAgeArgument(
774+
new MockOptions(npmFolder).withEnablePnpm(true))
775+
.isPresent());
776+
assertFalse(TaskRunNpmInstall
777+
.getMinimumFrontendPackageAgeArgument(
778+
new MockOptions(npmFolder).withEnableBun(true))
779+
.isPresent());
780+
}
781+
782+
@Test
783+
void minimumFrontendPackageAge_npm_addsBeforeArgument() {
784+
Options npmOptions = new MockOptions(npmFolder)
785+
.withMinimumFrontendPackageAgeDays(2);
786+
Optional<String> arg = TaskRunNpmInstall
787+
.getMinimumFrontendPackageAgeArgument(npmOptions);
788+
assertTrue(arg.isPresent());
789+
assertTrue(arg.get().startsWith("--before="),
790+
"npm should use --before, was: " + arg.get());
791+
}
792+
793+
@Test
794+
void minimumFrontendPackageAge_pnpm_addsMinimumReleaseAgeArgument() {
795+
Options pnpmOptions = new MockOptions(npmFolder).withEnablePnpm(true)
796+
.withMinimumFrontendPackageAgeDays(2);
797+
Optional<String> arg = TaskRunNpmInstall
798+
.getMinimumFrontendPackageAgeArgument(pnpmOptions);
799+
// 2 days = 2880 minutes; pnpm setting form
800+
assertEquals("--config.minimum-release-age=2880", arg.orElseThrow());
801+
}
802+
803+
@Test
804+
void minimumFrontendPackageAge_bun_addsMinimumReleaseAgeInSeconds() {
805+
Options bunOptions = new MockOptions(npmFolder).withEnableBun(true)
806+
.withMinimumFrontendPackageAgeDays(2);
807+
// 2 days = 172800 seconds
808+
assertEquals("--minimum-release-age=172800",
809+
TaskRunNpmInstall
810+
.getMinimumFrontendPackageAgeArgument(bunOptions)
811+
.orElseThrow());
812+
}
813+
766814
}

‎flow-plugins/flow-dev-bundle-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildDevBundleMojo.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,17 @@ public class BuildDevBundleMojo extends AbstractMojo
202202
@Parameter(property = FrontendUtils.PARAM_IGNORE_VERSION_CHECKS, defaultValue = "false")
203203
private boolean frontendIgnoreVersionChecks;
204204

205+
/**
206+
* Minimum age (in days) a frontend (npm) package version must have before
207+
* npm, pnpm or bun is allowed to install it. Mitigates supply-chain attacks
208+
* where a compromised version is briefly available on the registry.
209+
* Defaults to {@code 0} (disabled); set to a positive value to enable.
210+
* Requires pnpm &ge; 10.16.0 or bun &ge; 1.3.0 when those tools are used.
211+
*/
212+
@Parameter(property = "vaadin."
213+
+ InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS, defaultValue = "0")
214+
private int minimumFrontendPackageAgeDays;
215+
205216
/**
206217
* The folder where the META-INF/resources files are copied. Used for
207218
* finding the StyleSheet referenced css files.
@@ -552,6 +563,11 @@ public boolean isFrontendIgnoreVersionChecks() {
552563
return frontendIgnoreVersionChecks;
553564
}
554565

566+
@Override
567+
public int minimumFrontendPackageAgeDays() {
568+
return minimumFrontendPackageAgeDays;
569+
}
570+
555571
@Override
556572
public File resourcesOutputDirectory() {
557573
return resourcesOutputDirectory;

‎flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/GradlePluginAdapter.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,7 @@ internal class GradlePluginAdapter private constructor(
336336
return config.commercialWithBanner.get()
337337
}
338338

339+
override fun minimumFrontendPackageAgeDays(): Int =
340+
config.minimumFrontendPackageAgeDays.get()
341+
339342
}

‎flow-plugins/flow-gradle-plugin/src/main/kotlin/com/vaadin/gradle/VaadinFlowPluginExtension.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,16 @@ public abstract class VaadinFlowPluginExtension @Inject constructor(private val
343343
*/
344344
public abstract val frontendIgnoreVersionChecks: Property<Boolean>
345345

346+
/**
347+
* Minimum age (in days) a frontend (npm) package version must have before
348+
* npm, pnpm or bun is allowed to install it. Mitigates supply-chain
349+
* attacks where a compromised version is briefly available on the
350+
* registry. Defaults to {@code 0} (disabled); set to a positive value to
351+
* enable. Requires pnpm >= 10.16.0 or bun >= 1.3.0 when those tools are
352+
* used.
353+
*/
354+
public abstract val minimumFrontendPackageAgeDays: Property<Int>
355+
346356
/**
347357
* Allows building a version of the application with a commercial banner
348358
* when commercial components are used without a license key.
@@ -645,6 +655,12 @@ public class PluginEffectiveConfiguration(
645655
FrontendUtils.PARAM_IGNORE_VERSION_CHECKS
646656
)
647657

658+
public val minimumFrontendPackageAgeDays: Provider<Int> =
659+
project.getStringProperty(
660+
"vaadin.${InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS}"
661+
).map(String::toInt)
662+
.orElse(extension.minimumFrontendPackageAgeDays.convention(0))
663+
648664
public val npmExcludeWebComponents: Provider<Boolean> = extension
649665
.npmExcludeWebComponents.convention(false)
650666

‎flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/BuildFrontendMojo.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,17 @@ public class BuildFrontendMojo extends FlowModeAbstractMojo
145145
+ "resources/")
146146
private File resourcesOutputDirectory;
147147

148+
/**
149+
* Minimum age (in days) a frontend (npm) package version must have before
150+
* npm, pnpm or bun is allowed to install it. Mitigates supply-chain attacks
151+
* where a compromised version is briefly available on the registry.
152+
* Defaults to {@code 0} (disabled); set to a positive value to enable.
153+
* Requires pnpm &ge; 10.16.0 or bun &ge; 1.3.0 when those tools are used.
154+
*/
155+
@Parameter(property = "vaadin."
156+
+ InitParameters.MINIMUM_FRONTEND_PACKAGE_AGE_DAYS, defaultValue = "0")
157+
private int minimumFrontendPackageAgeDays;
158+
148159
@Override
149160
protected void executeInternal()
150161
throws MojoExecutionException, MojoFailureException {
@@ -303,6 +314,11 @@ public File resourcesOutputDirectory() {
303314
return resourcesOutputDirectory;
304315
}
305316

317+
@Override
318+
public int minimumFrontendPackageAgeDays() {
319+
return minimumFrontendPackageAgeDays;
320+
}
321+
306322
@Override
307323
public boolean checkRuntimeDependency(String groupId, String artifactId,
308324
Consumer<String> missingDependencyMessage) {

‎flow-plugins/flow-maven-plugin/src/main/java/com/vaadin/flow/plugin/maven/DeprecatedPropertyResolver.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ final class DeprecatedPropertyResolver {
5656
"vaadin.commercialWithBanner", "vaadin.ci.build",
5757
"vaadin.force.production.build",
5858
"vaadin.clean.build.frontend.files", "vaadin.path",
59-
"vaadin.useLit1", "vaadin.disableOptionalChaining");
59+
"vaadin.useLit1", "vaadin.disableOptionalChaining",
60+
"vaadin.npm.minimumFrontendPackageAgeDays");
6061

6162
private DeprecatedPropertyResolver() {
6263
}

0 commit comments

Comments
 (0)